├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── workflows │ └── build.yml ├── .gitignore ├── .java-version ├── .nvmrc ├── LICENSE ├── NOTICE ├── build.gradle ├── clevercloud ├── gradle.json └── hook.sh ├── docker-compose.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── init.sql ├── javascript ├── package.json ├── src │ ├── App.tsx │ ├── commons │ │ ├── User.tsx │ │ └── utils.tsx │ ├── components │ │ ├── CalendarDate.tsx │ │ ├── Fade.tsx │ │ └── Title.tsx │ ├── index.tsx │ ├── pages │ │ ├── CertificateHistoryPage.tsx │ │ ├── DomainsPage │ │ │ ├── DomainsPage.tsx │ │ │ └── StateAndProps.tsx │ │ ├── HomePage.tsx │ │ ├── UnauthorizedPage.tsx │ │ └── index.tsx │ ├── services │ │ ├── CertificatesServices.tsx │ │ ├── DomainsService.tsx │ │ └── NotificationsService.tsx │ └── styles │ │ ├── _variables.scss │ │ ├── components │ │ ├── _btn.scss │ │ ├── _calendarDate.scss │ │ ├── _checkbox.scss │ │ ├── _dropdown.scss │ │ ├── _form.scss │ │ ├── _input-search.scss │ │ ├── _input.scss │ │ ├── _loader.scss │ │ ├── _modal.scss │ │ ├── _nav.scss │ │ ├── _newVersion.scss │ │ ├── _panel.scss │ │ ├── _popover.scss │ │ ├── _reactTable.scss │ │ ├── _searchToolbar.scss │ │ ├── _selectReact.scss │ │ ├── _servicesMap.scss │ │ ├── _sweet-alert.scss │ │ ├── _switchButton.scss │ │ ├── _table.scss │ │ ├── _tableTransition.scss │ │ └── _toggleButton.scss │ │ ├── layout │ │ ├── _global.scss │ │ ├── _header.scss │ │ ├── _home.scss │ │ └── _sidebar.scss │ │ ├── main.scss │ │ ├── react-datetime.css │ │ └── theme │ │ └── bootstrap-theme.css ├── tsconfig.json ├── webpack.config.js └── yarn.lock ├── readme.md └── src ├── main ├── java │ └── fr │ │ └── maif │ │ └── automate │ │ ├── MainVerticle.kt │ │ ├── administrator │ │ └── Administrator.kt │ │ ├── certificate │ │ ├── CertificateRouter.kt │ │ ├── Certificates.kt │ │ ├── eventhandler │ │ │ ├── EventToCommandAdapter.kt │ │ │ └── TeamsEventHandler.kt │ │ ├── scheduler │ │ │ └── CertificateRenewer.kt │ │ ├── views │ │ │ ├── AllDomainView.kt │ │ │ └── EventsView.kt │ │ └── write │ │ │ ├── CertificateEventStore.kt │ │ │ ├── commands.kt │ │ │ ├── events.kt │ │ │ └── state.kt │ │ ├── commons │ │ ├── CertificateUtils.kt │ │ ├── Configuration.kt │ │ ├── Error.kt │ │ ├── HashUtils.kt │ │ ├── Http.kt │ │ ├── eventsourcing │ │ │ ├── InMemoryEventStore.kt │ │ │ ├── PostgresEventStore.kt │ │ │ └── eventsourcing.kt │ │ └── otoroshi.kt │ │ ├── dns │ │ ├── DnsManager.kt │ │ ├── DnsRouter.kt │ │ └── impl │ │ │ └── OvhDnsManager.kt │ │ ├── letsencrypt │ │ ├── LetSEncryptManager.kt │ │ └── impl │ │ │ └── LetSEncryptManagerImpl.kt │ │ └── publisher │ │ └── CertificatePublisher.kt └── resources │ ├── application.conf │ ├── db_changes.sql │ ├── dev.conf │ ├── logback.xml │ └── public │ ├── css │ ├── bootstrap-theme.min.css │ └── bootstrap.min.css │ ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 │ └── img │ └── letsAutomate.png └── test ├── java └── fr │ └── maif │ └── automate │ ├── certificate │ ├── scheduler │ │ └── CertificateRenewerTest.kt │ └── write │ │ ├── CertificateEventStoreTest.kt │ │ └── EventsSpec.kt │ ├── commons │ └── eventsourcing │ │ ├── PostgresEventStoreTest.kt │ │ └── StoreTest.kt │ ├── dns │ └── DnsManagerTest.kt │ └── matcher.kt └── resources └── logback.xml /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at oss@maif.fr. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/.github/CONTRIBUTING.md -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build and tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version-file: ".nvmrc" 16 | cache: "yarn" 17 | cache-dependency-path: javascript/yarn.lock 18 | - name: Set up JDK 19 | uses: actions/setup-java@v3 20 | with: 21 | java-version-file: ".java-version" 22 | distribution: "temurin" 23 | cache: "gradle" 24 | - name: Run postgres 25 | run: docker-compose up -d 26 | - name: build frontend 27 | run: | 28 | cd javascript 29 | yarn install 30 | yarn run build 31 | - name: Run build and tests 32 | run: ./gradlew test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | *.iml 3 | .idea 4 | 5 | javascript/node_modules 6 | src/main/resources/public/js/bundle 7 | .gradle 8 | logs 9 | out -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 21 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Copyright 2017-2018 MAIF and contributors 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | use this file except in compliance with the License. You may obtain a copy of 7 | the License at 8 | 9 | [http://www.apache.org/licenses/LICENSE-2.0] 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | License for the specific language governing permissions and limitations under 15 | the License. -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext { 3 | kotlin_version = '1.9.21' 4 | vertx_version = '4.5.0' 5 | arrow_version = "0.8.1" 6 | } 7 | 8 | repositories { 9 | mavenCentral() 10 | } 11 | } 12 | 13 | plugins { 14 | id "com.github.johnrengelman.shadow" version "8.1.1" 15 | id "application" 16 | id "java" 17 | id "org.jetbrains.kotlin.jvm" version "${kotlin_version}" 18 | id "org.jetbrains.kotlin.kapt" version "${kotlin_version}" 19 | } 20 | 21 | apply plugin: 'kotlin' 22 | 23 | def mainVerticleName = 'fr.maif.automate.MainVerticle' 24 | def launcherClassName = 'io.vertx.core.Launcher' 25 | 26 | application { 27 | mainClass.set(launcherClassName) 28 | } 29 | 30 | allprojects { 31 | repositories { 32 | mavenCentral() 33 | } 34 | } 35 | 36 | kotlin { 37 | jvmToolchain (21) 38 | } 39 | 40 | dependencies { 41 | implementation "io.vertx:vertx-core:$vertx_version" 42 | implementation "io.vertx:vertx-web:$vertx_version" 43 | implementation "io.vertx:vertx-web-client:$vertx_version" 44 | implementation "io.vertx:vertx-rx-java2:$vertx_version" 45 | implementation "io.vertx:vertx-lang-kotlin:$vertx_version" 46 | implementation "io.vertx:vertx-pg-client:$vertx_version" 47 | implementation "io.vertx:vertx-jdbc-client:$vertx_version" 48 | implementation "io.vertx:vertx-sql-client:$vertx_version" 49 | implementation 'io.reactivex.rxjava2:rxkotlin:2.4.0' 50 | implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.13.3' 51 | implementation 'org.postgresql:postgresql:42.3.6' 52 | implementation 'de.svenkubiak:jBCrypt:0.4.3' 53 | implementation 'com.auth0:java-jwt:3.19.2' 54 | implementation 'org.liquibase:liquibase-core:4.20.0' 55 | 56 | 57 | implementation 'com.typesafe:config:1.4.2' 58 | implementation 'org.shredzone.acme4j:acme4j-client:2.13' 59 | implementation 'org.shredzone.acme4j:acme4j-utils:2.13' 60 | implementation 'ch.qos.logback:logback-classic:1.2.11' 61 | 62 | implementation "io.arrow-kt:arrow-core:$arrow_version" 63 | implementation "io.arrow-kt:arrow-syntax:$arrow_version" 64 | implementation "io.arrow-kt:arrow-typeclasses:$arrow_version" 65 | implementation "io.arrow-kt:arrow-data:$arrow_version" 66 | implementation "io.arrow-kt:arrow-instances-core:$arrow_version" 67 | implementation "io.arrow-kt:arrow-instances-data:$arrow_version" 68 | 69 | implementation "io.arrow-kt:arrow-mtl:$arrow_version" 70 | implementation "io.arrow-kt:arrow-effects:$arrow_version" 71 | implementation "io.arrow-kt:arrow-effects-instances:$arrow_version" 72 | implementation "io.arrow-kt:arrow-effects-rx2:$arrow_version" 73 | implementation "io.arrow-kt:arrow-effects-rx2-instances:$arrow_version" 74 | implementation "io.arrow-kt:arrow-effects-kotlinx-coroutines:$arrow_version" 75 | 76 | implementation "org.jetbrains.kotlin:kotlin-stdlib" 77 | 78 | testImplementation 'io.kotlintest:kotlintest-runner-junit5:3.4.2' 79 | testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" 80 | testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' 81 | } 82 | 83 | // Redeploy watcher. 84 | run { 85 | if ((project.hasProperty('env') && project['env'] == 'dev') || 86 | (System.getenv('ENV') == 'dev')) { 87 | args = ['run', mainVerticleName, 88 | "--launcher-class=$launcherClassName", 89 | "--redeploy=src/**/*.*", 90 | "--on-redeploy=./gradlew classes"] 91 | } else { 92 | args = ['run', mainVerticleName, 93 | "--launcher-class=$launcherClassName"] 94 | } 95 | systemProperties = System.properties 96 | } 97 | 98 | // Naming and packaging settings for the "shadow jar". 99 | shadowJar { 100 | archiveBaseName = "letsautomate" 101 | archiveClassifier = 'shadow' 102 | 103 | manifest { 104 | attributes 'Main-Verticle': mainVerticleName 105 | } 106 | mergeServiceFiles { 107 | include 'META-INF/services/io.vertx.core.spi.VerticleFactory' 108 | } 109 | } 110 | 111 | //task wrapper(type: Wrapper) { 112 | // gradleVersion = '4.2.1' 113 | //} 114 | 115 | // Heroku relies on the 'stage' task to deploy. 116 | task stage { 117 | dependsOn shadowJar 118 | } 119 | 120 | test { 121 | useJUnitPlatform() 122 | 123 | testLogging { 124 | outputs.upToDateWhen { false } 125 | events "passed", "skipped", "failed", "standardOut", "standardError" 126 | } 127 | } -------------------------------------------------------------------------------- /clevercloud/gradle.json: -------------------------------------------------------------------------------- 1 | { 2 | "deploy": { 3 | "goal": "run" 4 | } 5 | } -------------------------------------------------------------------------------- /clevercloud/hook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Preparing build.gradle" 4 | 5 | LOCATION=`pwd` 6 | 7 | function build_ui { 8 | echo "Sourcing nvm" 9 | source /home/bas/.nvm/nvm.sh 10 | echo "using node" 11 | nvm install 12 | nvm use 13 | echo "Installing Yarn" 14 | npm install -g yarn 15 | echo "Installing JS deps in javascript" 16 | cd ./javascript 17 | yarn install 18 | echo "Running JS build.gradle" 19 | yarn run build 20 | echo "Destroying dependencies cache" 21 | rm -rf ./node_modules 22 | } 23 | 24 | build_ui -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: postgres 5 | environment: 6 | - POSTGRES_PASSWORD=password 7 | - POSTGRES_USER=postgres 8 | volumes: 9 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 10 | ports: 11 | - "54321:5432" 12 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE lets_automate; 2 | CREATE USER default_user WITH PASSWORD 'password'; 3 | GRANT ALL PRIVILEGES ON DATABASE lets_automate to default_user; 4 | 5 | ALTER DATABASE lets_automate OWNER to default_user; 6 | GRANT USAGE, CREATE ON SCHEMA PUBLIC TO default_user; 7 | 8 | CREATE DATABASE lets_automate_test; 9 | CREATE USER user_test WITH PASSWORD 'password_test'; 10 | GRANT ALL PRIVILEGES ON DATABASE lets_automate_test to user_test; 11 | 12 | ALTER DATABASE lets_automate_test OWNER to user_test; 13 | GRANT USAGE, CREATE ON SCHEMA PUBLIC TO user_test; -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "letsautomate", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "bundle": "cross-env NODE_ENV=production webpack", 8 | "build": "rm -f ../public/javascripts/bundle/* && npm run bundle", 9 | "start": "cross-env NODE_ENV=development webpack server --color" 10 | }, 11 | "author": "adelegue@hotmail.com", 12 | "license": "Apache-2.0", 13 | "dependencies": { 14 | "@types/jquery": "^3.5.16", 15 | "@types/lodash": "^4.14.191", 16 | "@types/moment": "^2.13.0", 17 | "@types/node-fetch": "^2.1.0", 18 | "@types/react": "18.0.28", 19 | "@types/react-dom": "18.0.11", 20 | "@types/react-router": "5.1.20", 21 | "@types/react-router-dom": "5.3.3", 22 | "@types/react-table": "^6.7.11", 23 | "@types/react-transition-group": "^4.4.5", 24 | "bootstrap": "3.4.1", 25 | "es-symbol": "^1.1.2", 26 | "jquery": "^3.6.4", 27 | "lodash": "^4.17.10", 28 | "moment": "^2.22.1", 29 | "query-string": "^8.1.0", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-router": "6.9.0", 33 | "react-router-dom": "6.9.0", 34 | "react-table": "^6.8.6", 35 | "react-transition-group": "^4.4.5", 36 | "whatwg-fetch": "^3.6.2" 37 | }, 38 | "devDependencies": { 39 | "babel-cli": "^6.26.0", 40 | "babel-core": "^6.26.3", 41 | "babel-loader": "^9.1.2", 42 | "babel-preset-es2015": "^6.24.1", 43 | "babel-preset-react": "^6.24.1", 44 | "babel-preset-stage-0": "^6.24.1", 45 | "cross-env": "^7.0.3", 46 | "css-loader": "^6.7.3", 47 | "file-loader": "^6.2.0", 48 | "json-loader": "^0.5.7", 49 | "node-sass": "8.0.0", 50 | "sass-loader": "^13.2.0", 51 | "source-map-loader": "^4.0.1", 52 | "style-loader": "^3.3.2", 53 | "transform-loader": "^0.2.4", 54 | "ts-loader": "^9.4.2", 55 | "typescript": "^4.9.5", 56 | "url-loader": "^4.1.1", 57 | "webpack": "^5.76.2", 58 | "webpack-cli": "^5.0.1", 59 | "webpack-dev-server": "^4.12.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /javascript/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Component} from 'react'; 3 | import {CertificateHistoryPage, DomainsPage, HomePage, UnauthorizedPage} from "./pages"; 4 | import {Navigate, Route, Routes} from 'react-router' 5 | import {BrowserRouter, Link} from 'react-router-dom' 6 | import {User} from "./commons/User"; 7 | 8 | 9 | export class LoggedApp extends Component { 10 | 11 | render() { 12 | const pathname = window.location.pathname; 13 | const className = (part: String) => part === pathname ? 'active' : 'inactive'; 14 | 15 | return ( 16 |
17 | 35 | 36 |
37 |
38 |
40 |
41 |
42 |
43 |
    44 |
  • 45 |

    Home 47 |

    48 | 49 |
  • 50 |
  • 51 | Domains 53 |
  • 54 |
55 |
56 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | 65 | }/> 66 | }/> 67 | }/> 68 | }/> 69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | ) 79 | } 80 | } 81 | 82 | export class LetSAutomate extends React.Component { 83 | 84 | render() { 85 | return ( 86 | 87 | }/>, 88 | }/> 90 | 91 | ) 92 | } 93 | } 94 | 95 | type PrivateRouteProps = { 96 | user: User, 97 | children: JSX.Element 98 | } 99 | 100 | const PrivateRoute: React.FC = (props: PrivateRouteProps) => { 101 | const {user, ...rest} = props; 102 | return ((!user || (user && !user.email)) ? : ) 103 | } 104 | 105 | type LetsRoutedProps = { 106 | user: User, 107 | logout: String 108 | } 109 | 110 | export const RoutedLetSAutomateApp = (props: LetsRoutedProps) => ( 111 | 112 | 113 | 114 | ); 115 | -------------------------------------------------------------------------------- /javascript/src/commons/User.tsx: -------------------------------------------------------------------------------- 1 | export interface User { 2 | email: String; 3 | } -------------------------------------------------------------------------------- /javascript/src/commons/utils.tsx: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /javascript/src/components/CalendarDate.tsx: -------------------------------------------------------------------------------- 1 | 2 | import * as React from 'react'; 3 | import {Moment} from "moment"; 4 | 5 | 6 | interface CalendarDateProps { 7 | date: Moment; 8 | } 9 | 10 | export const CalendarDate = (props: CalendarDateProps) => { 11 | const day: number = props.date.daysInMonth(); 12 | const year: number = props.date.year(); 13 | const month: string = props.date.format("MMMM"); 14 | return {props.date.format("DD MMMM YYYY")}; 15 | // return ( 16 | // 21 | // ); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /javascript/src/components/Fade.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {ReactNode} from "react"; 3 | import Transition from "react-transition-group/Transition"; 4 | 5 | 6 | export interface FadeProps { 7 | inProp: boolean; 8 | duration: number; 9 | children: ReactNode; 10 | onEntered: () => void; 11 | } 12 | 13 | export const Fade = (props: FadeProps) => { 14 | const duration = props.duration; 15 | const defaultStyle = { 16 | transition: `opacity ${duration}ms ease-in-out`, 17 | opacity: 1, 18 | }; 19 | 20 | const transitionStyles: any = { 21 | entering: { opacity: 0 }, 22 | entered: { opacity: 1 }, 23 | }; 24 | return ( 25 | 26 | {(state: any) => ( 27 |
31 | {props.children} 32 |
33 | )} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /javascript/src/components/Title.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Component} from 'react'; 3 | 4 | interface TitleProps { 5 | title: String; 6 | } 7 | 8 | export class Title extends Component { 9 | public render(): JSX.Element { 10 | if(!this.props.title) { 11 | return null; 12 | } else { 13 | return ( 14 |
15 |

16 | {this.props.title} 17 |

18 |
19 | ); 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /javascript/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOMClient from 'react-dom/client'; 3 | import {RoutedLetSAutomateApp} from './App' 4 | import * as $ from 'jquery'; 5 | import 'whatwg-fetch' 6 | import 'react-table/react-table.css'; 7 | 8 | import './styles/main.scss' 9 | import {User} from "./commons/User"; 10 | 11 | declare global { 12 | interface Window { 13 | $?: any; 14 | jQuery?: any; 15 | } 16 | } 17 | 18 | window.$ = $; 19 | window.jQuery = $; 20 | 21 | require('bootstrap/dist/js/bootstrap.min'); 22 | 23 | 24 | 25 | export function init(node: HTMLElement, strUser: string, logout: String) { 26 | let user: User = strUser ? JSON.parse(strUser) : null; 27 | const root = ReactDOMClient.createRoot(node); 28 | 29 | root.render(); 30 | } -------------------------------------------------------------------------------- /javascript/src/pages/CertificateHistoryPage.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {useParams} from "react-router" 3 | import {CertificateEvent, listCertificatesEvents} from "../services/CertificatesServices"; 4 | import ReactTable from "react-table"; 5 | import {Link} from "react-router-dom"; 6 | import React = require('react'); 7 | 8 | 9 | export function CertificateHistoryPage() { 10 | const [events, setEvents] = useState([]) 11 | const {id} = useParams(); 12 | useEffect(() => { 13 | listCertificatesEvents(id).then(events => setEvents(events)) 14 | }, [id]) 15 | 16 | function getColumns() { 17 | return [{ 18 | Header: 'Type', 19 | width: 300, 20 | Cell: (row: any) => { 21 | const d: CertificateEvent = row.original; 22 | return {d.type}; 23 | } 24 | }, { 25 | Header: 'Event', 26 | accessor: 'name', 27 | Cell: (row: any) => { 28 | const d: CertificateEvent = row.original; 29 | return {JSON.stringify(d.event)}; 30 | } 31 | }]; 32 | } 33 | 34 | return (
35 |
36 |

37 | 38 | History 39 |

40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
) 63 | 64 | } -------------------------------------------------------------------------------- /javascript/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Component} from 'react'; 3 | import {Title} from "../components/Title"; 4 | 5 | interface HomePageProps { 6 | 7 | } 8 | 9 | export class HomePage extends Component { 10 | public render() { 11 | return [ 12 | , 13 | <div className="row"> 14 | <div className="col-md-12"> 15 | <h1 className="text-center">Let's automate</h1> 16 | <p></p> 17 | <img className="logo_izanami_dashboard center-block" src="/assets/img/letsAutomate.png"/> 18 | </div> 19 | </div> 20 | ]; 21 | } 22 | } -------------------------------------------------------------------------------- /javascript/src/pages/UnauthorizedPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Component} from 'react'; 3 | 4 | 5 | export class UnauthorizedPage extends Component<{}> { 6 | public render(): JSX.Element { 7 | return (<span>Home</span>); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /javascript/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./UnauthorizedPage"; 2 | export * from "./HomePage"; 3 | export * from "./DomainsPage/DomainsPage"; 4 | export * from "./CertificateHistoryPage"; -------------------------------------------------------------------------------- /javascript/src/services/CertificatesServices.tsx: -------------------------------------------------------------------------------- 1 | import {Moment} from "moment"; 2 | 3 | export interface CertificateResume { 4 | expire: string; 5 | } 6 | export interface PublicationResume { 7 | publishDate: string; 8 | } 9 | 10 | export interface CertificateError { 11 | type: string; 12 | cause: string; 13 | } 14 | 15 | export interface CertificateInstanceResume { 16 | subdomain: string; 17 | wildcard?: boolean; 18 | certificate?: CertificateResume; 19 | publication?: PublicationResume; 20 | error?: CertificateError; 21 | } 22 | 23 | export interface DomainResume { 24 | domain?: string; 25 | certificates: CertificateInstanceResume[]; 26 | } 27 | 28 | export interface CreateCertificateCommand { domain: string; subdomain?: string; wildcard: boolean} 29 | export interface OrderCertificateCommand { domain: string; subdomain?: string; wildcard: boolean } 30 | export interface StartRenewCertificateCommand { domain: string; subdomain?: string; wildcard: boolean } 31 | export interface RenewCertificateCommand { domain: string; subdomain?: string; wildcard: boolean } 32 | export interface PublishCertificateCommand { domain: string; subdomain?: string } 33 | export interface DeleteCertificateCommand { domain: string; subdomain?: string } 34 | export type CertificateCommand = CreateCertificateCommand | OrderCertificateCommand | StartRenewCertificateCommand | RenewCertificateCommand | PublishCertificateCommand | DeleteCertificateCommand 35 | export interface Command { 36 | type: string; 37 | command: CertificateCommand; 38 | } 39 | 40 | export type CertificatePayload = 41 | CertificateCreated | 42 | CertificateOrdered | 43 | CertificateOrderFailure | 44 | CertificateReOrderedStarted | 45 | CertificateReOrdered | 46 | CertificateReOrderFailure | 47 | CertificatePublished | 48 | CertificatePublishFailure | 49 | CertificateDeleted 50 | export interface CertificateCreated { domain: string; subdomain?: string; wildcard: boolean;} 51 | export interface CertificateOrdered { domain: string; subdomain?: string; wildcard: boolean; expire: string;} 52 | export interface CertificateOrderFailure { domain: string; subdomain?: string; cause: string;} 53 | export interface CertificateReOrderedStarted { domain: string; subdomain?: string; wildcard: boolean; expire: string;} 54 | export interface CertificateReOrdered { domain: string; subdomain?: string; wildcard: boolean; expire: string;} 55 | export interface CertificateReOrderFailure {domain: string; subdomain?: string; cause: string;} 56 | export interface CertificatePublished { domain: string; subdomain?: string; dateTime: string;} 57 | export interface CertificatePublishFailure {domain: string; subdomain?: string; cause: string;} 58 | export interface CertificateDeleted {domain: string; subdomain?: string} 59 | export interface CertificateEvent { 60 | type: string; 61 | event: CertificatePayload; 62 | } 63 | 64 | export function CreateCertificate(command: CreateCertificateCommand): Command { 65 | return {type: 'CreateCertificate', command} 66 | } 67 | export function OrderCertificate(command: OrderCertificateCommand): Command { 68 | return {type: 'OrderCertificate', command} 69 | } 70 | export function StartRenewCertificate(command: StartRenewCertificateCommand): Command { 71 | return {type: 'StartRenewCertificate', command} 72 | } 73 | export function RenewCertificate(command: RenewCertificateCommand): Command { 74 | return {type: 'RenewCertificate', command} 75 | } 76 | export function PublishCertificate(command: PublishCertificateCommand): Command { 77 | return {type: 'PublishCertificate', command} 78 | } 79 | export function DeleteCertificate(command: DeleteCertificateCommand): Command { 80 | return {type: 'DeleteCertificate', command} 81 | } 82 | 83 | export function listCertificates(): Promise<Map<string, DomainResume>> { 84 | return fetch(`/api/certificates`, { 85 | method: 'GET', 86 | credentials: 'include', 87 | headers: { 88 | 'Accept': 'application/json' 89 | } 90 | }) 91 | .then(res => res.json()) 92 | .then((json: DomainResume[]) => { 93 | return new Map<string, DomainResume>(json.map(toTuple)); 94 | }); 95 | } 96 | 97 | export function listCertificatesEvents(id: string): Promise<CertificateEvent[]> { 98 | return fetch(`/api/certificates/${id}/_history`, { 99 | method: 'GET', 100 | credentials: 'include', 101 | headers: { 102 | 'Accept': 'application/json' 103 | } 104 | }) 105 | .then(res => res.json()); 106 | } 107 | 108 | let callback: ((evt:CertificateEvent) => void)[] = []; 109 | const evtSource = new EventSource("/api/certificates/_events"); 110 | evtSource.onmessage = function(e: any) { 111 | const event: CertificateEvent = JSON.parse(e.data); 112 | callback.forEach(fun => fun(event)); 113 | }; 114 | evtSource.onerror = function (e: any) { 115 | console.error("Error during sse", e); 116 | }; 117 | 118 | export function onCertificateEvent(cb: (evt:CertificateEvent) => void) { 119 | callback = [...callback, cb] 120 | } 121 | 122 | export function unregister(cb: (evt:CertificateEvent) => void) { 123 | callback = callback.filter(c => c != cb) 124 | } 125 | 126 | export function sendCommand(command: Command) { 127 | return fetch(`/api/certificates/_commands`, { 128 | method: 'POST', 129 | credentials: 'include', 130 | headers: { 131 | 'Content-Type': 'application/json', 132 | 'Accept': 'application/json' 133 | }, 134 | body: JSON.stringify(command) 135 | }) 136 | .then(res => 137 | res.json().then(json => 138 | [res.status, json] 139 | ) 140 | ); 141 | } 142 | 143 | 144 | function toTuple(c: DomainResume): [string, DomainResume]{ 145 | return [c.domain, c]; 146 | } -------------------------------------------------------------------------------- /javascript/src/services/DomainsService.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface Record { 4 | id: number; 5 | target: string; 6 | ttl: number; 7 | fieldType: string; 8 | subDomain: string; 9 | } 10 | 11 | export interface Domain { 12 | name: string; 13 | records: Record[]; 14 | } 15 | 16 | 17 | export function listDomains(): Promise<Map<string, Domain>> { 18 | return fetch(`/api/domains`, { 19 | method: 'GET', 20 | credentials: 'include', 21 | headers: { 22 | 'Accept': 'application/json' 23 | } 24 | }) 25 | .then(res => { 26 | if (res.status === 200) { 27 | return res.json().then((json: Domain[]) => { 28 | return new Map<string, Domain>(json.map(toTuple)); 29 | }) 30 | } else { 31 | return res.text().then( t => Promise.reject(t)); 32 | } 33 | }); 34 | } 35 | 36 | 37 | function toTuple(c: Domain): [string, Domain]{ 38 | return [c.name, c]; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /javascript/src/services/NotificationsService.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | export interface Notification { 4 | message: String; 5 | } 6 | 7 | export function sendNotification(notification: Notification) { 8 | 9 | } -------------------------------------------------------------------------------- /javascript/src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $primaryColor: #c8d400; 2 | $colorGris: #E6E6E6; 3 | $grisTexte:#b5b3b3; 4 | $fondNavbar: #494948; 5 | $fondBody: #373735; 6 | $fondGrisclairSurvolMenu: #9e9e9e; 7 | $savecolor : #5cb85c; 8 | $colorBleue : #00B4CD; 9 | $otoroshicolor : #f9b000; 10 | $colorRouge : #D5443F; 11 | $themeColor: #c8d400; 12 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_btn.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | background-color: transparent; 3 | background: none; 4 | margin-right: 5px; 5 | } 6 | 7 | .btnsService { 8 | position: fixed; 9 | width: 100%; 10 | z-index: 1000; 11 | background-color: $fondBody; 12 | padding-bottom: 16px; 13 | } 14 | 15 | .btnsService+.form-group { 16 | padding-top: 50px; 17 | } 18 | 19 | .btnsOtoroshi { 20 | margin-top: 10px; 21 | } 22 | .displayGroupBtn { 23 | text-align: right; 24 | button.btn { 25 | margin-left: 5px; 26 | } 27 | .btn-danger:focus { 28 | outline-color:$colorRouge; 29 | } 30 | } 31 | 32 | .btn-info:hover { 33 | color: #fff; 34 | background-color: $colorBleue; 35 | border-color: $colorBleue; 36 | } 37 | 38 | .btn-info, 39 | .btn_info:focus, 40 | .glyphicon-plus-sign { 41 | color: #fff; 42 | background-color: transparent; 43 | background-image: none; 44 | border-color: $colorBleue; 45 | } 46 | 47 | .btn-save { 48 | color:#fff; 49 | border-color: $savecolor; 50 | background-color: $savecolor; 51 | &[disabled] { 52 | color: $colorGris; 53 | border-color:$colorGris; 54 | background: none; 55 | } 56 | } 57 | 58 | // izanami begin 59 | .btn-active { 60 | border: 1px solid #5cb85c; 61 | background: #5cb85c !important; 62 | } 63 | 64 | .btn-search { 65 | border: 1px solid #5cb85c; 66 | background: none; 67 | margin: 2px 2px 2px 2px; 68 | } 69 | .buttonsBar { 70 | padding-top: 5px; 71 | margin-left: 5px; 72 | } 73 | .btn-dropdown { 74 | background: none; 75 | color: #48b9db; 76 | border: none; 77 | } 78 | // izanami end 79 | 80 | .input-group-btn:last-child>.btn, 81 | .input-group-btn:last-child>.btn-group { 82 | margin-left: 4px; 83 | } 84 | 85 | 86 | .input-group-btn:last-child>.btn { 87 | border-top-left-radius: 4px; 88 | border-bottom-left-radius: 4px; 89 | &:not(:last-child):not(.dropdown-toggle) { 90 | border-top-right-radius: 4px; 91 | border-bottom-right-radius: 4px; 92 | } 93 | } 94 | 95 | .content-switch-button { 96 | width: 35px; 97 | height: 22px; 98 | border-radius: 20px; 99 | display: flex; 100 | &-on { 101 | @extend .content-switch-button; 102 | background-color: $savecolor; 103 | border: 1px solid $savecolor; 104 | justify-content: flex-end; 105 | } 106 | &-off { 107 | @extend .content-switch-button; 108 | background-color: #fff; 109 | border: 1px solid #dfdfdf; 110 | justify-content: flex-start; 111 | } 112 | } 113 | 114 | .switch-button { 115 | background-color: #fff; 116 | border-radius: 20px; 117 | &-on { 118 | @extend .switch-button; 119 | width: 20px; 120 | height: 20px; 121 | background-color: #fff; 122 | border-radius: 20px; 123 | } 124 | &-off { 125 | @extend .switch-button; 126 | width: 22px; 127 | height: 22px; 128 | margin-top: -1px; 129 | margin-left: -1px; 130 | border: 1px solid #dfdfdf; 131 | box-shadow: 1px 0px 5px 0px rgba(0, 0, 0, 0.3); 132 | } 133 | } 134 | 135 | .btn-primary { 136 | color: #fff; 137 | border: 1px solid; 138 | border-color: $colorBleue; 139 | &:hover,&.active,&.active:hover { 140 | color: #fff; 141 | border: 1px solid; 142 | border-color: $colorBleue; 143 | background-color: $colorBleue; 144 | } 145 | } 146 | 147 | .btn-danger { 148 | color: #fff; 149 | border: 1px solid; 150 | border-color: $colorRouge; 151 | &:hover { 152 | color: #fff; 153 | border: 1px solid; 154 | border-color: $colorRouge; 155 | background-color: $colorRouge; 156 | } 157 | } 158 | 159 | .btn-success { 160 | color: #fff; 161 | border: 1px solid; 162 | border-color: $savecolor; 163 | &:hover { 164 | color: #fff; 165 | border: 1px solid; 166 | border-color: $savecolor; 167 | background-color: $savecolor; 168 | } 169 | &[disabled] { 170 | color: $colorGris; 171 | text-shadow: none; 172 | background-color: transparent; 173 | border-color: $savecolor; 174 | } 175 | &.disabled { 176 | color: $colorGris; 177 | text-shadow: none; 178 | background-color: transparent; 179 | border-color: $colorGris; 180 | } 181 | } 182 | 183 | .btn-default.disabled, 184 | .btn-default.disabled:hover { 185 | color: $colorGris; 186 | text-shadow: none; 187 | background-color: transparent; 188 | } 189 | 190 | .prod, 191 | .prod:hover { 192 | color: #fff; 193 | background-color: $savecolor; 194 | width: 80px; 195 | border-color: $savecolor; 196 | margin-bottom: 1px; 197 | } 198 | 199 | .preprod, 200 | .preprod:hover { 201 | color: #fff; 202 | background-color: $colorBleue; 203 | width: 80px; 204 | border-color: $colorBleue; 205 | margin-bottom: 1px; 206 | } 207 | 208 | .experiments, 209 | .experiments:hover { 210 | color: #fff; 211 | background-color: $primaryColor; 212 | width: 80px; 213 | border-color: $primaryColor; 214 | margin-bottom: 1px; 215 | } 216 | 217 | .refresh { 218 | background-color: $fondBody; 219 | border-color: $colorGris; 220 | color: $colorGris; 221 | background-image: none; 222 | margin-left: 10px; 223 | padding: 3px 5px 0px 5px; 224 | text-shadow: none; 225 | } 226 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_calendarDate.scss: -------------------------------------------------------------------------------- 1 | time.icon { 2 | font-size: 1em; /* change icon size */ 3 | display: block; 4 | position: relative; 5 | //width: 7em; 6 | //height: 7em; 7 | background-color: #fff; 8 | border-radius: 0.6em; 9 | box-shadow: 0 1px 0 #bdbdbd, 0 2px 0 #fff, 0 3px 0 #bdbdbd, 0 4px 0 #fff, 0 5px 0 #bdbdbd, 0 0 0 1px #bdbdbd; 10 | overflow: hidden; 11 | } 12 | 13 | time.icon * { 14 | display: block; 15 | width: 100%; 16 | font-size: 1em; 17 | font-weight: bold; 18 | font-style: normal; 19 | text-align: center; 20 | } 21 | 22 | time.icon strong { 23 | position: absolute; 24 | top: 0; 25 | padding: 0.4em 0; 26 | color: #fff; 27 | background-color: #fd9f1b; 28 | border-bottom: 1px dashed #f37302; 29 | box-shadow: 0 2px 0 #fd9f1b; 30 | } 31 | 32 | time.icon em { 33 | position: absolute; 34 | bottom: 0.3em; 35 | color: #fd9f1b; 36 | } 37 | 38 | time.icon span { 39 | font-size: 2.8em; 40 | letter-spacing: -0.05em; 41 | padding-top: 0.8em; 42 | color: #2f2f2f; 43 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_checkbox.scss: -------------------------------------------------------------------------------- 1 | input[type="checkbox"].ios8-switch { 2 | position: absolute; 3 | margin: 8px 0 0 16px !important; 4 | & + label { 5 | position: relative; 6 | padding: 5px 0 0 50px; 7 | line-height: 2.0em; 8 | display: inline; 9 | } 10 | & + label:before { 11 | content: ""; 12 | position: absolute; 13 | display: block; 14 | left: 0; 15 | top: 0; 16 | width: 40px; /* x*5 */ 17 | height: 24px; /* x*3 */ 18 | border-radius: 16px; /* x*2 */ 19 | background: #fff; 20 | border: 1px solid #d9d9d9; 21 | -webkit-transition: all 0.3s; 22 | transition: all 0.3s; 23 | } 24 | & + label:after { 25 | content: ""; 26 | position: absolute; 27 | display: block; 28 | left: 0px; 29 | top: 0px; 30 | width: 24px; /* x*3 */ 31 | height: 24px; /* x*3 */ 32 | border-radius: 16px; /* x*2 */ 33 | background: #fff; 34 | border: 1px solid #d9d9d9; 35 | -webkit-transition: all 0.3s; 36 | transition: all 0.3s; 37 | } 38 | } 39 | input[type="checkbox"].ios8-switch + label:hover:after { 40 | box-shadow: 0 0 5px rgba(0,0,0,0.3); 41 | } 42 | input[type="checkbox"].ios8-switch:checked + label:after { 43 | margin-left: 16px; 44 | } 45 | input[type="checkbox"].ios8-switch:checked + label:before { 46 | // @extend .bg-success; 47 | background: #55D069; 48 | } 49 | 50 | /* SMALL */ 51 | 52 | input[type="checkbox"].ios8-switch-sm { 53 | margin: 5px 0 0 10px; 54 | } 55 | input[type="checkbox"].ios8-switch-sm + label { 56 | position: relative; 57 | padding: 0 0 0 32px; 58 | line-height: 1.3em; 59 | } 60 | input[type="checkbox"].ios8-switch-sm + label:before { 61 | width: 25px; /* x*5 */ 62 | height: 15px; /* x*3 */ 63 | border-radius: 10px; /* x*2 */ 64 | } 65 | input[type="checkbox"].ios8-switch-sm + label:after { 66 | width: 15px; /* x*3 */ 67 | height: 15px; /* x*3 */ 68 | border-radius: 10px; /* x*2 */ 69 | } 70 | input[type="checkbox"].ios8-switch-sm + label:hover:after { 71 | box-shadow: 0 0 3px rgba(0,0,0,0.3); 72 | } 73 | input[type="checkbox"].ios8-switch-sm:checked + label:after { 74 | margin-left: 10px; /* x*2 */ 75 | } 76 | 77 | /* LARGE */ 78 | 79 | input[type="checkbox"].ios8-switch-lg { 80 | margin: 10px 0 0 20px; 81 | } 82 | input[type="checkbox"].ios8-switch-lg + label { 83 | position: relative; 84 | padding: 7px 0 0 60px; 85 | line-height: 2.3em; 86 | } 87 | input[type="checkbox"].ios8-switch-lg + label:before { 88 | width: 50px; /* x*5 */ 89 | height: 30px; /* x*3 */ 90 | border-radius: 20px; /* x*2 */ 91 | } 92 | input[type="checkbox"].ios8-switch-lg + label:after { 93 | width: 30px; /* x*3 */ 94 | height: 30px; /* x*3 */ 95 | border-radius: 20px; /* x*2 */ 96 | } 97 | input[type="checkbox"].ios8-switch-lg + label:hover:after { 98 | box-shadow: 0 0 8px rgba(0,0,0,0.3); 99 | } 100 | input[type="checkbox"].ios8-switch-lg:checked + label:after { 101 | margin-left: 20px; /* x*2 */ 102 | } 103 | 104 | .tdchecbox { 105 | padding-top: 0px !important; 106 | } 107 | 108 | .glyphicon-ok-sign, .text-success { 109 | color: $savecolor; 110 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_dropdown.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | .dropdown-menu{ 4 | background-color: $fondBody; 5 | } 6 | .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover {background-image: none; 7 | background-color: $fondGrisclairSurvolMenu; 8 | } 9 | .dropdown-menu>li>a { 10 | color: $colorGris; 11 | 12 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_form.scss: -------------------------------------------------------------------------------- 1 | .form-control[disabled], 2 | .form-control[readonly], 3 | fieldset[disabled] .form-control { 4 | background: none; 5 | border: 1px solid #6b6b6b; 6 | color: #858585; 7 | } 8 | 9 | .form-control:focus { 10 | border-color: $primaryColor; 11 | box-shadow: none; 12 | } 13 | 14 | form.new-service { 15 | align-content: center; 16 | margin-left: 10px; 17 | margin-right: -10px; 18 | } 19 | 20 | .form-addon { 21 | margin-left: 23px; 22 | width: 98%; 23 | } 24 | 25 | label { 26 | color: #fff; 27 | } 28 | 29 | .has-error .control-label { 30 | color: $colorRouge; 31 | } 32 | 33 | .has-error .form-control { 34 | border-color: $colorRouge; 35 | } 36 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_input-search.scss: -------------------------------------------------------------------------------- 1 | 2 | $medium-gray: #fcc; 3 | $dark-gray: #303131; 4 | .material-input-primary { 5 | position: relative; 6 | height: 60px; 7 | font-size: 15px; 8 | line-height: 15px; 9 | font-weight:300; 10 | margin: 10px 0 3px 0; 11 | &::after, &::before { 12 | content: ''; 13 | position: absolute; 14 | top: 40px; 15 | left: 0; 16 | height: 1px; 17 | background-color: $primaryColor; 18 | width: 100%; 19 | transition: height 0.3s; 20 | } 21 | &::after { 22 | background-color: $primaryColor; 23 | transform: scaleX(0); 24 | transition: transform 0.3s; 25 | } 26 | &.is-focused { 27 | .field-label { 28 | color: $primaryColor; 29 | } 30 | &::after { 31 | transform: scaleX(1); 32 | } 33 | } 34 | &.has-label { 35 | .field-label { 36 | transform: translateY(0) scale(0.90); 37 | } 38 | } 39 | } 40 | .material_input--search { 41 | position: relative; 42 | height: 60px; 43 | font-size: 15px; 44 | line-height: 15px; 45 | font-weight:300; 46 | margin: 10px 0 3px 0; 47 | &::after, &::before { 48 | content: ''; 49 | position: absolute; 50 | top: 40px; 51 | left: 0; 52 | height: 1px; 53 | background-color: $primaryColor; 54 | width: 100%; 55 | transition: height 0.3s; 56 | } 57 | &::after { 58 | background-color: $primaryColor; 59 | transform: scaleX(0); 60 | transition: transform 0.3s; 61 | } 62 | &.is-focused { 63 | .field-label { 64 | color: $primaryColor; 65 | } 66 | &::after { 67 | transform: scaleX(1); 68 | } 69 | } 70 | &.has-label { 71 | .field-label { 72 | transform: translateY(0) scale(0.90); 73 | } 74 | } 75 | .field-error { 76 | top: -22px; 77 | margin-right: 20px; 78 | color: #cd2431; 79 | pointer-events: none; 80 | } 81 | } 82 | .field-label { 83 | display: block; 84 | position: relative; 85 | font-weight:300; 86 | // color: $dark-gray default; 87 | transform: translateY(23px); 88 | transform-origin: 0 50%; 89 | transition: transform 0.3s; 90 | padding: 0; 91 | margin: 0; 92 | height: 20px; 93 | } 94 | .field-input { 95 | position: relative; 96 | padding-bottom: 2px; 97 | display: block; 98 | width: 100%; 99 | height: 20px; 100 | line-height: 20px; 101 | background-color: transparent; 102 | border: none; 103 | -webkit-appearance: none; 104 | outline: none; 105 | padding: 0; 106 | margin: 0; 107 | } 108 | .field-error { 109 | display: block; 110 | position: relative; 111 | font-weight:300; 112 | text-align: right; 113 | color: #f8ac59; 114 | padding: 0; 115 | margin: 0; 116 | height: 20px; 117 | line-height:20px; 118 | } 119 | .field-reset { 120 | position: absolute; 121 | right: 0; 122 | top: 20px; 123 | visibility: hidden; 124 | &:hover { 125 | color: #cd2431; 126 | } 127 | } 128 | .has-label { 129 | .field-reset { 130 | visibility: visible; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_input.scss: -------------------------------------------------------------------------------- 1 | input[type=search]::-webkit-search-cancel-button { 2 | -webkit-appearance: searchfield-cancel-button; 3 | } 4 | 5 | input.form-control{ 6 | color:$colorGris; 7 | background-color: $fondNavbar; 8 | border-color: $fondBody; 9 | } 10 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | border: 3px solid #f3f3f3; /* Light grey */ 3 | border-top: 3px solid #3498db; /* Blue */ 4 | border-radius: 50%; 5 | width: 20px; 6 | height: 20px; 7 | animation: spin 2s linear infinite; 8 | } 9 | 10 | @keyframes spin { 11 | 0% { transform: rotate(0deg); } 12 | 100% { transform: rotate(360deg); } 13 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | bottom: 0; 6 | left: 0; 7 | z-index: 1050; 8 | margin: 0 auto; 9 | width: 50%; 10 | } 11 | 12 | .modal-dialog { 13 | width: 80%; 14 | margin: 30px auto; 15 | } 16 | 17 | .modal-content { 18 | position: relative; 19 | background-color: $fondBody; 20 | background-clip: padding-box; 21 | border: 1px solid rgba(0,0,0,.2); 22 | border-radius: 6px; 23 | outline: 0; 24 | } 25 | 26 | .modal-header { 27 | padding: 15px; 28 | border-bottom: 1px solid $primaryColor; 29 | font-size: 18px; 30 | } 31 | 32 | .modal-body { 33 | position: relative; 34 | padding: 15px; 35 | font-size: 16px; 36 | 37 | label { 38 | font-size: 14px; 39 | } 40 | 41 | .form-group { 42 | margin-top: 30px; 43 | } 44 | 45 | strong { 46 | color: $colorBleue; 47 | } 48 | } 49 | 50 | .modal-footer { 51 | padding: 15px; 52 | text-align: right; 53 | border-top: 1px solid $primaryColor; 54 | } 55 | 56 | pre { 57 | height: 600px; 58 | } 59 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_nav.scss: -------------------------------------------------------------------------------- 1 | .nav-tabs { 2 | border-bottom: 1px solid $primaryColor; 3 | position: fixed; 4 | top: 179px; 5 | width: 100%; 6 | background-color: $fondBody; 7 | z-index: 10; 8 | } 9 | 10 | .nav-tabs > li.active > a, 11 | .nav-tabs > li.active > a:focus, 12 | .nav-tabs > li.active > a:hover { 13 | color: $colorGris; 14 | cursor: default; 15 | background-color: $primaryColor; 16 | border: 1px solid $primaryColor; 17 | border-bottom-color: transparent; 18 | } 19 | 20 | .nav-tabs > li > a { 21 | margin-right: 5px; 22 | border: 1px solid $primaryColor; 23 | } 24 | 25 | .tab-content { 26 | margin-top: 200px; 27 | } 28 | 29 | .nav > li > a { 30 | transition-duration: 1s; 31 | 32 | &:focus, 33 | &:hover { 34 | background-color: $fondGrisclairSurvolMenu; 35 | color: #fff; 36 | border-color: $primaryColor; 37 | } 38 | } 39 | 40 | .nav > li > h3 > a { 41 | padding: 5px 0 5px 30px; 42 | margin-left: -30px; 43 | display: block; 44 | transition-duration: 1s; 45 | 46 | &:focus, 47 | &:hover { 48 | text-decoration: none; 49 | background-color: $fondGrisclairSurvolMenu; 50 | color: #fff; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_newVersion.scss: -------------------------------------------------------------------------------- 1 | .newVersionPopup { 2 | background-color: $primaryColor; 3 | height: 70px; 4 | width: 410px; 5 | position: fixed; 6 | top: 0px; 7 | right: 0px; 8 | z-index: 100000; 9 | color: white; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | padding-left: 10px; 14 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_panel.scss: -------------------------------------------------------------------------------- 1 | .panel-heading { 2 | text-align: right; 3 | .close { 4 | font-size: 40px; 5 | line-height: 0.4; 6 | } 7 | } 8 | 9 | .panel, 10 | .panel-heading { 11 | background-color: $fondBody; 12 | 13 | h4 { 14 | color: #fff; 15 | } 16 | } 17 | 18 | .panel-success > .panel-heading { 19 | background-image: -webkit-linear-gradient(top,$savecolor 0,$savecolor 100%); 20 | background-image: -o-linear-gradient(top,$savecolor 0,$savecolor 100%); 21 | background-image: -webkit-gradient(linear,left top,left bottom,from($savecolor),to($savecolor)); 22 | background-image: linear-gradient(to bottom,$savecolor 0,$savecolor 100%); 23 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr= '$savecolor', endColorstr='$savecolor', GradientType=0); 24 | color:#fff; 25 | } 26 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_popover.scss: -------------------------------------------------------------------------------- 1 | .popover{ 2 | color: $fondBody; 3 | } 4 | .popover-title{ 5 | background-color: #c9302c; 6 | color: #FFF; 7 | } 8 | 9 | .popover.bottom { 10 | margin-left: -100px; 11 | } 12 | 13 | .popover .close{ 14 | margin-top: -5px;; 15 | } 16 | 17 | .popover.right>.arrow:after { 18 | border-right-color: #c9302c; 19 | } 20 | 21 | .popover-content{ 22 | background-color: #c9302c; 23 | color: #FFF; 24 | } 25 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_reactTable.scss: -------------------------------------------------------------------------------- 1 | .ReactTable { 2 | border: none !important; 3 | 4 | input { 5 | color: $colorGris !important; 6 | background-color: $fondNavbar !important; 7 | border-color: $fondBody !important; 8 | border-radius: 4px; 9 | border: none; 10 | } 11 | 12 | .rt-td { 13 | height: 40px !important; 14 | } 15 | 16 | .-pageSizeOptions { 17 | display: none !important; 18 | } 19 | 20 | .-next .-btn { 21 | color: $colorGris !important; 22 | border: 1px solid $colorGris !important; 23 | border-radius: 4px !important; 24 | } 25 | 26 | .-previous .-btn { 27 | color: $colorGris !important; 28 | border: 1px solid $colorGris !important; 29 | border-radius: 4px !important; 30 | } 31 | 32 | .-pagination { 33 | justify-content: center; 34 | } 35 | 36 | .-pagination .-previous, 37 | .-pagination .-next { 38 | flex: none; 39 | } 40 | 41 | .-pagination .-center { 42 | flex: none; 43 | border: 1px solid rgba(181, 179, 179, .5); 44 | border-radius: 4px; 45 | margin-left: 5px; 46 | margin-right: 5px; 47 | } 48 | 49 | .-pagination .-btn:not([disabled]):hover { 50 | background: #494948; 51 | } 52 | 53 | span.label.table-env { 54 | margin-right: 10px; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_searchToolbar.scss: -------------------------------------------------------------------------------- 1 | .search-zone { 2 | position: absolute; 3 | background:$fondNavbar; 4 | width: 400px; 5 | border: 1px solid $fondBody; 6 | border-top:0; 7 | margin-top: -2px; 8 | .btn { 9 | color:white; 10 | } 11 | .btn:focus{ 12 | outline: $savecolor; 13 | } 14 | } 15 | 16 | .search-input { 17 | -webkit-transition: all .51s !important; 18 | -moz-transition: all .5s !important; 19 | transition: all .5s !important; 20 | width: 200px ; 21 | } 22 | .search-input:focus { 23 | width:400px !important; 24 | } 25 | 26 | i.search-loading { 27 | right: 25px; 28 | top: 10px !important; 29 | } 30 | 31 | .search-loading-zone { 32 | right: 25px!important; 33 | } 34 | 35 | .results { 36 | padding: 10px; 37 | overflow: auto; 38 | border: 1px solid #40403f; 39 | margin: 5px 5px 10px 5px; 40 | background-color: #585856; 41 | } 42 | 43 | .Select-loading { 44 | width: 16px; 45 | height: 16px; 46 | -webkit-animation: Select-animation-spin 400ms infinite linear; 47 | -o-animation: Select-animation-spin 400ms infinite linear; 48 | animation: Select-animation-spin 400ms infinite linear; 49 | box-sizing: border-box; 50 | border-radius: 50%; 51 | border: floor((16 / 8)) solid #ccc; 52 | border-right-color: #333; 53 | display: inline-block; 54 | position: relative; 55 | vertical-align: middle; 56 | } 57 | 58 | @keyframes Select-animation-spin { 59 | to { transform: rotate(1turn); } 60 | } 61 | @-webkit-keyframes Select-animation-spin { 62 | to { -webkit-transform: rotate(1turn); } 63 | } 64 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_selectReact.scss: -------------------------------------------------------------------------------- 1 | .Select-menu-outer { 2 | background-color: #6b6b6b; 3 | z-index: 9999; 4 | } 5 | //zone recherche quand select open 6 | .is-open > .Select-control { 7 | background-color: $fondNavbar; 8 | color: #fff; 9 | } 10 | // élément en focus 11 | .Select-option.is-focused { 12 | color: #fff; 13 | background-color: $fondNavbar; 14 | } 15 | 16 | .Select-option.is-focused:hover{ 17 | background-color: $fondNavbar; 18 | } 19 | 20 | .Select-control,.Select-option { 21 | background-color: #373735; 22 | } 23 | 24 | .Select-control { 25 | background: none; 26 | border: none; 27 | } 28 | 29 | .Select.is-open > .Select-control { 30 | background-color: $fondNavbar; 31 | } 32 | 33 | .Select-control .Select-input:focus { 34 | outline: none; 35 | background: $fondNavbar; 36 | } 37 | 38 | .Select-clear-zone:hover { 39 | color: #666; 40 | } 41 | // couleur de text du select 42 | .Select-option { 43 | color: #8C8C8B; 44 | } 45 | 46 | .Select-value,.Select.has-value.Select--single > .Select-control .Select-value .Select-value-label, .Select.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value .Select-value-label { 47 | color:$colorGris; 48 | background-color: $fondNavbar; 49 | } 50 | 51 | .Select-placeholder, 52 | .Select--single > .Select-control .Select-value { 53 | color:$colorGris; 54 | background-color: $fondNavbar; 55 | } 56 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_servicesMap.scss: -------------------------------------------------------------------------------- 1 | .services-map { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 100%; 6 | } 7 | 8 | .services-map text { 9 | font-size: 11px; 10 | pointer-events: none; 11 | } 12 | 13 | .services-map text.parent { 14 | fill: #1f77b4; 15 | } 16 | 17 | .services-map circle { 18 | fill: $primaryColor; 19 | stroke: #999; 20 | pointer-events: all; 21 | } 22 | 23 | .services-map circle.parent { 24 | fill: #1f77b4; 25 | fill-opacity: .1; 26 | stroke: steelblue; 27 | } 28 | 29 | .services-map circle.parent:hover { 30 | stroke: $primaryColor; 31 | stroke-width: .5px; 32 | } 33 | 34 | .services-map circle.child { 35 | pointer-events: none; 36 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_sweet-alert.scss: -------------------------------------------------------------------------------- 1 | .sweet-alert{ 2 | h2,button{ 3 | color:#777 4 | } 5 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_switchButton.scss: -------------------------------------------------------------------------------- 1 | 2 | .content-switch-button-on{ 3 | width: 35px; 4 | height: 22px; 5 | background-color: $themeColor; 6 | border-radius: 20px; 7 | border: 1px solid $themeColor; 8 | display: flex; 9 | justify-content: flex-end; 10 | cursor: pointer; 11 | } 12 | 13 | .content-switch-button-off{ 14 | width: 35px; 15 | height: 22px; 16 | background-color: white; 17 | border-radius: 20px; 18 | border: 1px solid rgb(223, 223, 223); 19 | display: flex; 20 | justify-content: flex-start; 21 | 22 | } 23 | 24 | .switch-button-on{ 25 | width: 20px; 26 | height: 20px; 27 | background-color: white; 28 | border-radius: 20px; 29 | } 30 | 31 | .switch-button-off{ 32 | width: 22px; 33 | height: 22px; 34 | background-color: white; 35 | border-radius: 20px; 36 | margin-top: -1px; 37 | margin-left: -1px; 38 | border: 1px solid rgb(223, 223, 223); 39 | box-shadow: 1px 0px 5px 0px rgba(0, 0, 0, 0.3); 40 | } 41 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_table.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | color: $fondGrisclairSurvolMenu; 3 | 4 | tr { 5 | // cursor: pointer; 6 | } 7 | } 8 | 9 | .table-striped > tbody > tr:nth-of-type(odd) { 10 | background-color: $fondNavbar; 11 | } 12 | 13 | .table-hover tbody tr:hover td, 14 | .table-hover tbody tr:hover th { 15 | background-color: $fondNavbar; 16 | color: #fff; 17 | } 18 | 19 | .table > tbody > tr > td, 20 | .table > tbody > tr > th, 21 | .table > tfoot > tr > td, 22 | .table > tfoot > tr > th, 23 | .table > thead > tr > td, 24 | .table > thead > tr > th, 25 | .table-bordered, 26 | .table-bordered > tbody > tr > td, 27 | .table-bordered > tbody > tr > th, 28 | .table-bordered > tfoot > tr > td, 29 | .table-bordered > tfoot > tr > th, 30 | .table-bordered > thead > tr > td, 31 | .table-bordered > thead > tr > th { 32 | border: none; 33 | } 34 | 35 | thead > tr, 36 | thead > tr > td > span { 37 | color: $colorGris; 38 | } 39 | 40 | .pagination > .active > a, 41 | .pagination > .active > a:focus, 42 | .pagination > .active > a:hover, 43 | .pagination > .active > span, 44 | .pagination > .active > span:focus, 45 | .pagination > .active > span:hover { 46 | background-color: $fondNavbar; 47 | border-color: $colorGris; 48 | } 49 | 50 | .pagination > li > a, 51 | .pagination > li > span { 52 | background: none; 53 | color: $colorGris; 54 | } 55 | 56 | .titleTable { 57 | cursor: pointer; 58 | display: flex; 59 | justify-content: space-between; 60 | padding: 5px 5px 5px 10px; 61 | } 62 | 63 | .active > .titleTable { 64 | background-color: $colorBleue; 65 | color: #fff; 66 | border: thin solid $fondGrisclairSurvolMenu; 67 | } 68 | 69 | .titleTable:hover { 70 | background-color: lighten($colorBleue,5%); 71 | color: #fff; 72 | } 73 | 74 | thead { 75 | background-color: lighten($colorBleue,5%); 76 | } 77 | 78 | .fa-download { 79 | cursor: pointer; 80 | color: $colorBleue; 81 | } 82 | 83 | .table-liste { 84 | flex: 1 1 100%; 85 | -webkit-box-pack: center; 86 | -ms-flex-pack: center; 87 | justify-content: center; 88 | text-align: center; 89 | } 90 | 91 | .link-service { 92 | color: $colorGris; 93 | text-decoration: none; 94 | 95 | &:hover { 96 | color: #fff; 97 | text-decoration: none; 98 | } 99 | } 100 | 101 | .formAdd { 102 | text-align: center; 103 | } 104 | 105 | table .displayGroupBtn{ 106 | min-width: 100px; 107 | } 108 | -------------------------------------------------------------------------------- /javascript/src/styles/components/_tableTransition.scss: -------------------------------------------------------------------------------- 1 | 2 | .fade-enter { 3 | opacity: 0.01; 4 | } 5 | .fade-enter-active { 6 | opacity: 1; 7 | transition: opacity 500ms ease-in; 8 | } 9 | .fade-exit { 10 | opacity: 1; 11 | } 12 | .fade-exit-active { 13 | opacity: 0.01; 14 | transition: opacity 500ms ease-in; 15 | } 16 | 17 | 18 | 19 | 20 | 21 | 22 | .status-enter { 23 | opacity: 0.01; 24 | transform: scale(0.9) translateY(50%); 25 | } 26 | .status-enter-active { 27 | opacity: 1; 28 | transform: scale(1) translateY(0%); 29 | transition: all 300ms ease-out; 30 | } 31 | .status-exit { 32 | opacity: 1; 33 | transform: scale(1) translateY(0%); 34 | } 35 | .status-exit-active { 36 | opacity: 0.01; 37 | transform: scale(0.9) translateY(50%); 38 | transition: all 300ms ease-out; 39 | } 40 | 41 | 42 | .letsencrypt-enter { 43 | opacity: 0.01; 44 | transform: scale(0.9) translateY(50%); 45 | } 46 | .letsencrypt-enter-active { 47 | opacity: 1; 48 | transform: scale(1) translateY(0%); 49 | transition: all 300ms ease-out; 50 | } 51 | .letsencrypt-exit { 52 | opacity: 1; 53 | transform: scale(1) translateY(0%); 54 | } 55 | .letsencrypt-exit-active { 56 | opacity: 0.01; 57 | transform: scale(0.9) translateY(50%); 58 | transition: all 300ms ease-out; 59 | } 60 | 61 | 62 | .clever-enter { 63 | opacity: 0.01; 64 | transform: scale(0.9) translateY(50%); 65 | } 66 | .clever-enter-active { 67 | opacity: 1; 68 | transform: scale(1) translateY(0%); 69 | transition: all 300ms ease-out; 70 | } 71 | .clever-exit { 72 | opacity: 1; 73 | transform: scale(1) translateY(0%); 74 | } 75 | .clever-exit-active { 76 | opacity: 0.01; 77 | transform: scale(0.9) translateY(50%); 78 | transition: all 300ms ease-out; 79 | } -------------------------------------------------------------------------------- /javascript/src/styles/components/_toggleButton.scss: -------------------------------------------------------------------------------- 1 | /* The switch - the box around the slider */ 2 | .switch { 3 | position: relative; 4 | display: inline-block; 5 | width: 60px; 6 | height: 34px; 7 | } 8 | 9 | /* Hide default HTML checkbox */ 10 | .switch input {display:none;} 11 | 12 | /* The slider */ 13 | .slider { 14 | position: absolute; 15 | cursor: pointer; 16 | top: 0; 17 | left: 0; 18 | right: 0; 19 | bottom: 0; 20 | background-color: #ccc; 21 | -webkit-transition: .4s; 22 | transition: .4s; 23 | } 24 | 25 | .slider:before { 26 | position: absolute; 27 | content: ""; 28 | height: 26px; 29 | width: 26px; 30 | left: 4px; 31 | bottom: 4px; 32 | background-color: #fff; 33 | -webkit-transition: .4s; 34 | transition: .4s; 35 | } 36 | 37 | input:checked + .slider { 38 | background-color: #5cb85c; 39 | } 40 | 41 | input:focus + .slider { 42 | box-shadow: 0 0 1px #5cb85c; 43 | } 44 | 45 | input:checked + .slider:before { 46 | -webkit-transform: translateX(26px); 47 | -ms-transform: translateX(26px); 48 | transform: translateX(26px); 49 | } 50 | 51 | /* Rounded sliders */ 52 | .slider.round { 53 | border-radius: 34px; 54 | 55 | width: 60px; 56 | height: 33px; 57 | } 58 | 59 | .slider.round:before { 60 | border-radius: 50%; 61 | } 62 | -------------------------------------------------------------------------------- /javascript/src/styles/layout/_global.scss: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 60px; 3 | padding-bottom: 20px; 4 | font-family: Raleway, Helvetica, sans-serif; 5 | font-weight: 500; 6 | background-color: $fondBody; 7 | color: $grisTexte; 8 | } 9 | 10 | a, 11 | a:hover { 12 | color: $primaryColor 13 | } 14 | 15 | .main { 16 | padding-right: 30px; 17 | padding-left: 30px; 18 | margin-top: -10px; 19 | } 20 | .page-header { 21 | margin-top: 32px; 22 | border-bottom-color: $primaryColor; 23 | margin-left: 25px 24 | } 25 | 26 | .placeholders { 27 | margin-bottom: 30px; 28 | text-align: center; 29 | } 30 | .placeholders h4 { 31 | margin-bottom: 0; 32 | } 33 | .placeholder { 34 | margin-bottom: 20px; 35 | } 36 | .placeholder img { 37 | display: inline-block; 38 | border-radius: 50%; 39 | } 40 | 41 | .menu { 42 | float: left; 43 | color: #fff 44 | } 45 | 46 | .page-header,h3, 47 | h4 { 48 | color: $primaryColor 49 | } 50 | 51 | 52 | @mixin h3fixed{ 53 | position: fixed; 54 | background-color: #373735; 55 | width: 100%; 56 | z-index: 110; 57 | margin-left: -25px; 58 | } 59 | .fixedH3 { 60 | @include h3fixed; 61 | // height: 130px; 62 | } 63 | 64 | .fixedH3withoutHeight { 65 | @include h3fixed; 66 | } 67 | 68 | .fixedH3+.row, .fixedH3withoutHeight+.row { 69 | padding-top: 88px 70 | } 71 | 72 | 73 | @media only screen and (max-width:767px) { 74 | .backoffice-container { 75 | margin-top: 56px 76 | } 77 | .sidebar { 78 | display: none; 79 | width: 100% 80 | } 81 | } 82 | 83 | .backoffice-container>h3+.row { 84 | margin-left: 0px; 85 | margin-right: -10px; 86 | } 87 | .backoffice-container>.row { 88 | margin-left: 0px; 89 | } 90 | .backoffice-container .glyphicon-ok-sign,.backoffice-container .glyphicon-remove-sign{ 91 | font-size: 16px; 92 | top: 4px; 93 | } 94 | 95 | //izanami begin 96 | .izanami-container>h3+.row { 97 | margin-left: 0px; 98 | margin-right: -10px; 99 | } 100 | .izanami-container>.row { 101 | margin-left: 0px; 102 | margin-right: -5px; 103 | } 104 | .izanami-container .glyphicon-ok-sign,.izanami-container .glyphicon-remove-sign{ 105 | font-size: 16px; 106 | top: 4px; 107 | } 108 | 109 | .fixedH3{ 110 | margin-top: -10px; 111 | } 112 | //izanmi end 113 | 114 | .backoffice-container .label { 115 | margin-right: 5px; 116 | } 117 | 118 | .content{ 119 | margin-top:50px; 120 | } 121 | 122 | .tab-content a,.tab-content a:hover { 123 | color: $grisTexte; 124 | } 125 | 126 | .nav-tabs.nav > li > a:focus, .nav-tabs.nav > li > a:hover { 127 | background-color: $primaryColor; 128 | cursor: pointer; 129 | } 130 | 131 | .logoOtoroshi { 132 | width: 240px; 133 | margin-top: 60px; 134 | margin-bottom: 0; 135 | } 136 | 137 | .logo_izanami_dashboard { 138 | margin-top: 30px; 139 | width: 240px; 140 | } 141 | 142 | .ReactTable .rt-expander:after{ 143 | border-top-color: rgb(180, 179, 179); 144 | } 145 | 146 | .subTable{ 147 | background-color: #444441; 148 | } 149 | 150 | .addCertificate{ 151 | margin-top: 30px; 152 | margin-left: 30px; 153 | } 154 | -------------------------------------------------------------------------------- /javascript/src/styles/layout/_header.scss: -------------------------------------------------------------------------------- 1 | .navbar-inverse { 2 | background-color: $fondNavbar; 3 | background-image: none; 4 | border: none; 5 | .navbar-brand { 6 | -webkit-flex-direction: row; 7 | flex-direction: row; 8 | color: #fff; 9 | font-weight: 700; 10 | text-shadow: none; 11 | justify-content: center; 12 | text-align: center; 13 | width: 100%; 14 | font-size: 18px; 15 | cursor: pointer; 16 | } 17 | .navbar-nav>li { 18 | >a{ 19 | color: $colorGris; 20 | } 21 | .dropdown-menu { 22 | margin-top: -1px; 23 | } 24 | >a:hover, >a:focus { 25 | color : $primaryColor; 26 | } 27 | } 28 | 29 | .navbar-nav>.active>a, .navbar-nav>.open>a,.navbar-nav>.open>a:focus, .navbar-nav>.open>a:hover { 30 | background-color: $fondBody; 31 | background-image: none; 32 | color: $primaryColor; 33 | } 34 | 35 | .navbar-right > li > a { 36 | color: $primaryColor; 37 | &:focus,&:hover { 38 | background:none; 39 | cursor: pointer; 40 | } 41 | } 42 | 43 | i.glyphicon.glyphicon-off { 44 | margin-left: 4px; 45 | } 46 | } 47 | 48 | .navbar-header { 49 | height: 52px; 50 | background-color: $primaryColor; 51 | } 52 | 53 | .navbar-inverse .navbar-nav>li>a, .glyphicon-cog { 54 | color: $primaryColor; 55 | } 56 | 57 | // izanimi begin 58 | 59 | .text-info { 60 | color: $themeColor; 61 | } 62 | 63 | .input-group-addon{ 64 | background: none; 65 | border: none; 66 | } 67 | 68 | .form-control:focus { 69 | border-color: #373735; 70 | box-shadow: none; 71 | } 72 | 73 | // izanami end 74 | 75 | .fa-cog { 76 | font-size: 1.5em; 77 | top: 5px; 78 | } 79 | 80 | #navbar .Select-control { 81 | border: 1px solid #ccc; 82 | } 83 | 84 | .nav > li > a { 85 | padding: 15px 15px; 86 | } 87 | 88 | @media (max-width:1300px) { 89 | .navbar-inverse .navbar-brand { 90 | flex-direction: column; 91 | } 92 | } 93 | 94 | @media (max-width:1200px) { 95 | .navbar-inverse .navbar-brand { 96 | font-size: 14px 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /javascript/src/styles/layout/_home.scss: -------------------------------------------------------------------------------- 1 | /*ACCUEIL*/ 2 | .accueil { 3 | margin-top: -60px; 4 | font-family: Raleway, Helvetica, sans-serif; 5 | 6 | .navbar-brand, 7 | .navbar-nav > li > a { 8 | text-shadow: none; 9 | } 10 | 11 | .navbar-right > li > a { 12 | .glyphicon { 13 | margin-left: 2px; 14 | } 15 | 16 | &:hover { 17 | background: none; 18 | cursor: pointer; 19 | color: lighten($primaryColor,10%); 20 | } 21 | } 22 | 23 | .navbar-brand { 24 | background-color: $primaryColor; 25 | color: #fff; 26 | font-weight: 700; 27 | transition-duration: 0.5s; 28 | flex-direction: column; 29 | text-align: center; 30 | 31 | &:hover { 32 | background-color: lighten($primaryColor,5%); 33 | } 34 | } 35 | 36 | .jumbotron { 37 | background-color: #494948; 38 | padding-top: 20px; 39 | margin-top: 20px; 40 | 41 | h1 { 42 | text-align: center; 43 | color: #fff; 44 | font-weight: 700; 45 | margin-bottom: 40px; 46 | } 47 | 48 | .form-horizontal .form-group{ 49 | margin-right: 0px; 50 | } 51 | } 52 | 53 | .btnsAccueil { 54 | margin-bottom: 10px; 55 | } 56 | 57 | .homeProductCard { 58 | border-radius: 5px; 59 | margin: 5px; 60 | border: 1px solid $primaryColor; 61 | transition-duration: 0.5s; 62 | 63 | &:hover { 64 | background-color: $primaryColor; 65 | 66 | .title-card { 67 | color: #fff; 68 | } 69 | } 70 | 71 | h4 { 72 | text-align: center; 73 | font-size: 18px; 74 | } 75 | 76 | .title-card { 77 | color: $primaryColor; 78 | transition-duration: 0.5s; 79 | } 80 | } 81 | 82 | .containerLogo { 83 | text-align: center; 84 | } 85 | 86 | .logo_Omoikane { 87 | width: 240px; 88 | margin-top: 40px; 89 | animation-name: floatInSpace; 90 | -webkit-animation-name: floatInSpace; 91 | animation-iteration-count: infinite; 92 | -webkit-animation-iteration-count: infinite; 93 | animation-timing-function: linear; 94 | -webkit-animation-timing-function: linear; 95 | animation-duration: 15s; 96 | -webkit-animation-duration: 15s; 97 | } 98 | 99 | .logo_OmoikaneDashboard { 100 | width: 240px; 101 | } 102 | @keyframes floatInSpace { 103 | 0% { 104 | transform: perspective(500px) translate3d(2px, 1px, 0px); 105 | } 106 | 107 | 8% { 108 | transform: perspective(500px) translate3d(5px, -2px, -3px); 109 | } 110 | 111 | 16% { 112 | transform: perspective(500px) translate3d(8px, 1px, -7px); 113 | } 114 | 115 | 24% { 116 | transform: perspective(500px) translate3d(10px, 2px, -10px); 117 | } 118 | 119 | 32% { 120 | transform: perspective(500px) translate3d(7px, 4px, -13px); 121 | } 122 | 123 | 40% { 124 | transform: perspective(500px) translate3d(6px, 6px, -17px); 125 | } 126 | 127 | 48% { 128 | transform: perspective(500px) translate3d(4px, 7px, -20px); 129 | } 130 | 131 | 56% { 132 | transform: perspective(500px) translate3d(1px, 6px, -17px); 133 | } 134 | 135 | 64% { 136 | transform: perspective(500px) translate3d(-2px, 7px, -13px); 137 | } 138 | 139 | 73% { 140 | transform: perspective(500px) translate3d(-2px, 4px, -10px); 141 | } 142 | 143 | 81% { 144 | transform: perspective(500px) translate3d(-4px, 3px, -7px); 145 | } 146 | 147 | 91% { 148 | transform: perspective(500px) translate3d(-2px, 2px, -3px); 149 | } 150 | 151 | 100% { 152 | transform: perspective(500px) translate3d(2px, 1px, 0px); 153 | } 154 | } 155 | @-webkit-keyframes floatInSpace { 156 | 0% { 157 | transform: perspective(500px) translate3d(2px, 1px, 0px); 158 | } 159 | 160 | 8% { 161 | transform: perspective(500px) translate3d(5px, -2px, -3px); 162 | } 163 | 164 | 16% { 165 | transform: perspective(500px) translate3d(8px, 1px, -7px); 166 | } 167 | 168 | 24% { 169 | transform: perspective(500px) translate3d(10px, 2px, -10px); 170 | } 171 | 172 | 32% { 173 | transform: perspective(500px) translate3d(7px, 4px, -13px); 174 | } 175 | 176 | 40% { 177 | transform: perspective(500px) translate3d(6px, 6px, -17px); 178 | } 179 | 180 | 48% { 181 | transform: perspective(500px) translate3d(4px, 7px, -20px); 182 | } 183 | 184 | 56% { 185 | transform: perspective(500px) translate3d(1px, 6px, -17px); 186 | } 187 | 188 | 64% { 189 | transform: perspective(500px) translate3d(-2px, 7px, -13px); 190 | } 191 | 192 | 73% { 193 | transform: perspective(500px) translate3d(-2px, 4px, -10px); 194 | } 195 | 196 | 81% { 197 | transform: perspective(500px) translate3d(-4px, 3px, -7px); 198 | } 199 | 200 | 91% { 201 | transform: perspective(500px) translate3d(-2px, 2px, -3px); 202 | } 203 | 204 | 100% { 205 | transform: perspective(500px) translate3d(2px, 1px, 0px); 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /javascript/src/styles/layout/_sidebar.scss: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | background: -moz-linear-gradient(top, $fondBody 1%, $fondNavbar 100%); 3 | background: -webkit-linear-gradient(top, $fondBody 1%, $fondNavbar); 4 | background: linear-gradient(180deg, $fondBody 1%, $fondNavbar); 5 | position: fixed; 6 | top: 51px; 7 | bottom: 0; 8 | left: 0; 9 | z-index: 1000; 10 | display: block; 11 | padding: 20px; 12 | overflow-x: hidden; 13 | overflow-y: auto; 14 | border-right: none; 15 | 16 | .active a { 17 | color: $primaryColor; 18 | } 19 | 20 | a { 21 | color: $colorGris; 22 | } 23 | 24 | h2 a, 25 | h3 { 26 | color: $primaryColor; 27 | font-size: 15px; 28 | text-transform: uppercase; 29 | margin-left: 10px; 30 | } 31 | 32 | i { 33 | margin-right: 5px; 34 | } 35 | } 36 | 37 | .sidebar-container { 38 | display: flex; 39 | height: 100%; 40 | flex-direction: column; 41 | } 42 | 43 | .sidebar-content { 44 | flex-grow: 1; 45 | } 46 | 47 | .nav-sidebar { 48 | margin-right: -21px; 49 | margin-left: -20px; 50 | margin-bottom: 20px; 51 | 52 | > li > a { 53 | padding: 5px 10px 5px 36px; 54 | &.active{ 55 | color: $primaryColor; 56 | } 57 | } 58 | } 59 | 60 | .nav-sidebar-plus { 61 | padding-left: 20px; 62 | margin-top: -15px; 63 | 64 | > li > a { 65 | padding: 5px 10px 5px 36px; 66 | 67 | &:hover { 68 | margin-left: -20px; 69 | padding-left: 56px; 70 | } 71 | } 72 | } 73 | 74 | .logoContent { 75 | text-align: center; 76 | margin-top: 20px; 77 | } 78 | 79 | .logo { 80 | width: 50%; 81 | left: 25%; 82 | } 83 | -------------------------------------------------------------------------------- /javascript/src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'variables'; 2 | 3 | @import 'components/checkbox'; 4 | @import 'components/table'; 5 | @import 'components/btn'; 6 | @import 'components/input-search'; 7 | @import 'components/nav'; 8 | @import 'components/dropdown'; 9 | @import 'components/form'; 10 | @import 'components/input'; 11 | @import 'components/reactTable'; 12 | @import 'components/modal'; 13 | @import 'components/panel'; 14 | @import 'components/sweet-alert'; 15 | @import 'components/toggleButton'; 16 | @import 'components/selectReact'; 17 | @import 'components/popover'; 18 | @import 'components/newVersion'; 19 | @import 'components/servicesMap'; 20 | @import 'components/switchButton'; 21 | @import 'components/searchToolbar'; 22 | @import 'components/calendarDate'; 23 | @import 'components/tableTransition'; 24 | @import 'components/loader'; 25 | 26 | @import 'layout/global'; 27 | @import 'layout/header'; 28 | @import 'layout/sidebar'; 29 | @import 'layout/home'; 30 | -------------------------------------------------------------------------------- /javascript/src/styles/react-datetime.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * https://github.com/YouCanBookMe/react-datetime 3 | */ 4 | 5 | .rdt { 6 | position: relative; 7 | } 8 | .rdtPicker { 9 | display: none; 10 | position: absolute; 11 | width: 250px; 12 | padding: 4px; 13 | margin-top: 1px; 14 | z-index: 99999 !important; 15 | background: #fff; 16 | box-shadow: 0 1px 3px rgba(0,0,0,.1); 17 | border: 1px solid #f9f9f9; 18 | } 19 | .rdtOpen .rdtPicker { 20 | display: block; 21 | } 22 | .rdtStatic .rdtPicker { 23 | box-shadow: none; 24 | position: static; 25 | } 26 | 27 | .rdtPicker .rdtTimeToggle { 28 | text-align: center; 29 | } 30 | 31 | .rdtPicker table { 32 | width: 100%; 33 | margin: 0; 34 | } 35 | .rdtPicker td, 36 | .rdtPicker th { 37 | text-align: center; 38 | height: 28px; 39 | } 40 | .rdtPicker td { 41 | cursor: pointer; 42 | } 43 | .rdtPicker td.rdtDay:hover, 44 | .rdtPicker td.rdtHour:hover, 45 | .rdtPicker td.rdtMinute:hover, 46 | .rdtPicker td.rdtSecond:hover, 47 | .rdtPicker .rdtTimeToggle:hover { 48 | background: #eeeeee; 49 | cursor: pointer; 50 | } 51 | .rdtPicker td.rdtOld, 52 | .rdtPicker td.rdtNew { 53 | color: #999999; 54 | } 55 | .rdtPicker td.rdtToday { 56 | position: relative; 57 | } 58 | .rdtPicker td.rdtToday:before { 59 | content: ''; 60 | display: inline-block; 61 | border-left: 7px solid transparent; 62 | border-bottom: 7px solid #428bca; 63 | border-top-color: rgba(0, 0, 0, 0.2); 64 | position: absolute; 65 | bottom: 4px; 66 | right: 4px; 67 | } 68 | .rdtPicker td.rdtActive, 69 | .rdtPicker td.rdtActive:hover { 70 | background-color: #428bca; 71 | color: #fff; 72 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 73 | } 74 | .rdtPicker td.rdtActive.rdtToday:before { 75 | border-bottom-color: #fff; 76 | } 77 | .rdtPicker td.rdtDisabled, 78 | .rdtPicker td.rdtDisabled:hover { 79 | background: none; 80 | color: #999999; 81 | cursor: not-allowed; 82 | } 83 | 84 | .rdtPicker td span.rdtOld { 85 | color: #999999; 86 | } 87 | .rdtPicker td span.rdtDisabled, 88 | .rdtPicker td span.rdtDisabled:hover { 89 | background: none; 90 | color: #999999; 91 | cursor: not-allowed; 92 | } 93 | .rdtPicker th { 94 | border-bottom: 1px solid #f9f9f9; 95 | } 96 | .rdtPicker .dow { 97 | width: 14.2857%; 98 | border-bottom: none; 99 | cursor: default; 100 | } 101 | .rdtPicker th.rdtSwitch { 102 | width: 100px; 103 | } 104 | .rdtPicker th.rdtNext, 105 | .rdtPicker th.rdtPrev { 106 | font-size: 21px; 107 | vertical-align: top; 108 | } 109 | 110 | .rdtPrev span, 111 | .rdtNext span { 112 | display: block; 113 | -webkit-touch-callout: none; /* iOS Safari */ 114 | -webkit-user-select: none; /* Chrome/Safari/Opera */ 115 | -khtml-user-select: none; /* Konqueror */ 116 | -moz-user-select: none; /* Firefox */ 117 | -ms-user-select: none; /* Internet Explorer/Edge */ 118 | user-select: none; 119 | } 120 | 121 | .rdtPicker th.rdtDisabled, 122 | .rdtPicker th.rdtDisabled:hover { 123 | background: none; 124 | color: #999999; 125 | cursor: not-allowed; 126 | } 127 | .rdtPicker thead tr:first-child th { 128 | cursor: pointer; 129 | } 130 | .rdtPicker thead tr:first-child th:hover { 131 | background: #eeeeee; 132 | } 133 | 134 | .rdtPicker tfoot { 135 | border-top: 1px solid #f9f9f9; 136 | } 137 | 138 | .rdtPicker button { 139 | border: none; 140 | background: none; 141 | cursor: pointer; 142 | } 143 | .rdtPicker button:hover { 144 | background-color: #eee; 145 | } 146 | 147 | .rdtPicker thead button { 148 | width: 100%; 149 | height: 100%; 150 | } 151 | 152 | td.rdtMonth, 153 | td.rdtYear { 154 | height: 50px; 155 | width: 25%; 156 | cursor: pointer; 157 | } 158 | td.rdtMonth:hover, 159 | td.rdtYear:hover { 160 | background: #eee; 161 | } 162 | 163 | .rdtCounters { 164 | display: inline-block; 165 | } 166 | 167 | .rdtCounters > div { 168 | float: left; 169 | } 170 | 171 | .rdtCounter { 172 | height: 100px; 173 | } 174 | 175 | .rdtCounter { 176 | width: 40px; 177 | } 178 | 179 | .rdtCounterSeparator { 180 | line-height: 100px; 181 | } 182 | 183 | .rdtCounter .rdtBtn { 184 | height: 40%; 185 | line-height: 40px; 186 | cursor: pointer; 187 | display: block; 188 | 189 | -webkit-touch-callout: none; /* iOS Safari */ 190 | -webkit-user-select: none; /* Chrome/Safari/Opera */ 191 | -khtml-user-select: none; /* Konqueror */ 192 | -moz-user-select: none; /* Firefox */ 193 | -ms-user-select: none; /* Internet Explorer/Edge */ 194 | user-select: none; 195 | } 196 | .rdtCounter .rdtBtn:hover { 197 | background: #eee; 198 | } 199 | .rdtCounter .rdtCount { 200 | height: 20%; 201 | font-size: 1.2em; 202 | } 203 | 204 | .rdtMilli { 205 | vertical-align: middle; 206 | padding-left: 8px; 207 | width: 48px; 208 | } 209 | 210 | .rdtMilli input { 211 | width: 100%; 212 | font-size: 1.2em; 213 | margin-top: 37px; 214 | } 215 | 216 | .rdtTime td { 217 | cursor: default; 218 | } -------------------------------------------------------------------------------- /javascript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "es6", 8 | "lib": ["es6", "dom"], 9 | "jsx": "react" 10 | }, 11 | "include": [ 12 | "./src/**/*" 13 | ] 14 | } -------------------------------------------------------------------------------- /javascript/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | 4 | const isDev = process.env.NODE_ENV !== "production"; 5 | 6 | const plugins = [ 7 | new webpack.DefinePlugin({ 8 | '__DEV__': process.env.NODE_ENV === 'production', 9 | 'process.env': { 10 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development') 11 | } 12 | }) 13 | ]; 14 | 15 | if (isDev) { 16 | plugins.push(new webpack.HotModuleReplacementPlugin()); 17 | plugins.push(new webpack.NoEmitOnErrorsPlugin()); 18 | } 19 | 20 | 21 | module.exports = { 22 | mode: process.env.NODE_ENV, 23 | entry: { 24 | LetsAutomate: "./src/index.tsx" 25 | }, 26 | output: { 27 | publicPath: '/assets/bundle/', 28 | path: path.resolve(__dirname, '../src/main/resources/public/bundle/'), 29 | filename: '[name].js', 30 | library: '[name]', 31 | libraryTarget: 'umd' 32 | }, 33 | 34 | devtool : isDev ? "inline-source-map" : false, 35 | 36 | resolve: { 37 | // Add '.ts' and '.tsx' as resolvable extensions. 38 | extensions: [".ts", ".tsx", ".js", ".json", ".css", ".scss"] 39 | }, 40 | 41 | devServer: { 42 | port: 3336 43 | }, 44 | 45 | module: { 46 | rules: [ 47 | {test: /\.tsx?$/, use: "ts-loader", exclude: "/node_modules/"}, 48 | { 49 | test: /\.js|\.jsx|\.es6$/, 50 | exclude: /node_modules/, 51 | use: ['babel-loader'] 52 | }, 53 | {enforce: "pre", test: /\.js$/, loader: "source-map-loader"}, 54 | { 55 | test: /node_modules\/auth0-lock\/.*\.js$/, 56 | use: [ 57 | 'transform-loader/cacheable?brfs', 58 | 'transform-loader/cacheable?packageify' 59 | ] 60 | }, 61 | { 62 | test: /node_modules\/auth0-lock\/.*\.ejs$/, 63 | use: ['transform-loader/cacheable?ejsify'] 64 | }, 65 | { 66 | test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 67 | use: ['file-loader'] 68 | }, 69 | { 70 | test: /\.json$/, 71 | use: ['json-loader'] 72 | }, 73 | { 74 | test: /\.scss$/, 75 | use: ['style-loader', 'css-loader', 'sass-loader'] 76 | }, 77 | { 78 | test: /\.css$/, 79 | exclude: /\.useable\.css$/, 80 | use: ["style-loader", "css-loader"] 81 | }, 82 | { 83 | test: /\.useable\.css$/, 84 | use: ["style-loader/useable", "css-loader"] 85 | } 86 | ] 87 | }, 88 | 89 | plugins: plugins, 90 | 91 | // When importing a module whose path matches one of the following, just 92 | // assume a corresponding global variable exists and use that instead. 93 | // This is important because it allows us to avoid bundling all of our 94 | // dependencies, which allows browsers to cache those libraries between builds. 95 | // externals: { 96 | // "react": "React", 97 | // "react-dom": "ReactDOM" 98 | // } 99 | }; -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Let's Automate 2 | 3 | Automate Let's Encrypt certificate issuance, renewal and synchronize with CleverCloud (or any API-drivable hosting service). 4 | 5 | <p align="center"> 6 | <img src="https://github.com/MAIF/lets-automate/raw/master/src/main/resources/public/img/letsAutomate.png?token=ABgKYW3Y2Gn5vNsGYGSAJjWaPA4ZTZSZks5bQ1bCwA%3D%3D" height="250"> 7 | </img> 8 | </p> 9 | 10 | ## Description 11 | 12 | Let's automate allows you to create Let's Encrypt certificates and publish them to Clever Cloud with automatic renewal (or any API-drivable hosting service if you want to contribute). 13 | Let's automate needs an OVH account in order to create DNS records to perform the [Let's Encrypt DNS challenge](https://blog.sebian.fr/letsencrypt-dns/). Let's automate is also integrated with Teams so all the events may be published to a dedicated topic. 14 | 15 | ## Disclamer 16 | 17 | Let's Automate is integrated with Otoroshi (only used for authentication), OVH, Clever Cloud and Teams. For the moment there is no other providers available. 18 | If you need this tool with any other DNS provider or hosting provider your contributions are welcome! 19 | 20 | ## Deploy the app 21 | 22 | ### Build the app 23 | 24 | ``` 25 | git clone https://github.com/MAIF/lets-automate.git 26 | nvm use 27 | cd javascript 28 | yarn install 29 | yarn build 30 | cd .. 31 | gradlew shadowJar 32 | ``` 33 | 34 | The jar file is located in the folder `build/libs/letsautomate-shadow.jar` 35 | 36 | 37 | ### Ovh Key 38 | 39 | First you need to get a token to access ovh apis 40 | 41 | https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/ 42 | 43 | ```bash 44 | 45 | curl -XPOST -H "X-Ovh-Application: YOUR_APPLICATION_ID" -H "Content-type: application/json" \ 46 | https://eu.api.ovh.com/1.0/auth/credential -d '{ 47 | "accessRules": [ 48 | { 49 | "method": "GET", 50 | "path": "/*" 51 | }, 52 | { 53 | "method": "POST", 54 | "path": "/*" 55 | }, 56 | { 57 | "method": "PUT", 58 | "path": "/*" 59 | }, 60 | { 61 | "method": "DELETE", 62 | "path": "/*" 63 | } 64 | ], 65 | "redirection":"https://localhost:8080" 66 | }' --include 67 | 68 | HTTP/1.1 200 OK 69 | Date: Mon, 25 Jun 2018 08:57:43 GMT 70 | Server: Apache 71 | X-OVH-QUERYID: FR.ws-3.5b30ae87.26037.1707 72 | Cache-Control: no-cache 73 | Access-Control-Allow-Origin: * 74 | Transfer-Encoding: chunked 75 | Content-Type: application/json; charset=utf-8 76 | 77 | {"validationUrl":"https://eu.api.ovh.com/auth/?credentialToken=A_CREDENTIAL_TOKEN","consumerKey":"A_CONSUMER_KEY","state":"pendingValidation"}% 78 | 79 | ``` 80 | 81 | Then go to the validation url and log in. 82 | 83 | Set the consumer key, your application id and secret in the configuration file. 84 | 85 | ### Configuration 86 | 87 | | System Property | Env variable | Default | 88 | |-------------------------------------| ------------- | ------------- | 89 | | env | ENV | dev | 90 | | http.port | HTTP_PORT | 8080 | 91 | | http.host | HTTP_HOST | 0.0.0.0 | 92 | | logout | LOGOUT_URL | | 93 | | certificates.pollingInterval.period | LETSENCRYPT_POLLING_PERIOD | 5 | 94 | | certificates.pollingInterval.unit | LETSENCRYPT_POLLING_UNIT | HOUR | 95 | | ovh.applicationKey | OVH_APPLICATION_KEY | | 96 | | ovh.applicationSecret | OVH_APPLICATION_SECRET | | 97 | | ovh.consumerKey | OVH_CONSUMER_KEY | | 98 | | ovh.host | OVH_HOST | https://api.ovh.com | 99 | | letsencrypt.server | LETSENCRYPT_SERVER | acme://letsencrypt.org/staging | 100 | | letsencrypt.accountId | LETSENCRYPT_ACCOUNT_ID | account | 101 | | postgres.host | POSTGRESQL_ADDON_HOST | localhost | 102 | | postgres.port | POSTGRESQL_ADDON_PORT | 5432 | 103 | | postgres.database | POSTGRESQL_ADDON_DB | lets_automate | 104 | | postgres.username | POSTGRESQL_ADDON_USER | default_user | 105 | | postgres.password | POSTGRESQL_ADDON_PASSWORD | password | 106 | | clevercloud.host | CLEVER_HOST | https://api.clever-cloud.com/ | 107 | | clevercloud.consumerKey | CLEVER_CONSUMER_KEY | | 108 | | clevercloud.consumerSecret | CLEVER_CONSUMER_SECRET | | 109 | | clevercloud.clientToken | CLEVER_CLIENT_TOKEN | | 110 | | clevercloud.clientSecret | CLEVER_CLIENT_SECRET | | 111 | | otoroshi.headerRequestId | FILTER_REQUEST_ID_HEADER_NAME | | 112 | | otoroshi.headerGatewayStateResp | FILTER_GATEWAY_STATE_RESP_HEADER_NAME | | 113 | | otoroshi.headerGatewayState | FILTER_GATEWAY_STATE_HEADER_NAME | | 114 | | otoroshi.headerClaim | FILTER_CLAIM_HEADER_NAME | | 115 | | otoroshi.sharedKey | CLAIM_SHAREDKEY | | 116 | | otoroshi.issuer | OTOROSHI_ISSUER | | 117 | | teams.url | TEAMS_URL | | 118 | 119 | ### Run the app 120 | 121 | ``` 122 | java -jar letsautomate-shadow.jar \ 123 | -Denv=prod \ 124 | -Dovh.applicationKey=xxxx \ 125 | -Dovh.applicationSecret=xxxx \ 126 | -Dovh.consumerKey=xxxx \ 127 | -Dletsencrypt.server=acme://letsencrypt.org \ 128 | -Dclevercloud.consumerKey=xxxx \ 129 | -Dclevercloud.consumerSecret=xxxx \ 130 | -Dclevercloud.clientToken=xxxx \ 131 | -Dclevercloud.clientSecret=xxxx \ 132 | -Dteams.url=xxxx 133 | 134 | ``` 135 | 136 | ### Run the app with clever cloud 137 | 138 | First create a postgresql add on. 139 | 140 | Then create a java app and set the following env variables : 141 | 142 | ``` 143 | APP_ENV=prod 144 | CACHE_DEPENDENCIES=true 145 | CC_PRE_BUILD_HOOK=./clevercloud/hook.sh 146 | CLEVER_CLIENT_SECRET=xxxx 147 | CLEVER_CLIENT_TOKEN=xxxx 148 | CLEVER_CONSUMER_KEY=xxxx 149 | CLEVER_CONSUMER_SECRET=xxxx 150 | CLEVER_HOST=https://api.clever-cloud.com 151 | ENV=prod 152 | JAVA_VERSION=8 153 | LETSENCRYPT_ACCOUNT_ID=account 154 | LETSENCRYPT_POLLING_PERIOD=1 155 | LETSENCRYPT_POLLING_UNIT=HOURS 156 | LETSENCRYPT_SERVER=acme://letsencrypt.org 157 | OVH_APPLICATION_KEY=xxxx 158 | OVH_APPLICATION_SECRET=xxxx 159 | OVH_CONSUMER_KEY=xxxx 160 | OVH_HOST=https://api.ovh.com 161 | PORT=8080 162 | TEAMS_URL=xxxx 163 | ``` 164 | 165 | ## Run in development 166 | 167 | ### Run the app 168 | 169 | ```bash 170 | 171 | docker-compose up 172 | 173 | OVH_APPLICATION_KEY=xxxx OVH_APPLICATION_SECRET=xxxx OVH_CONSUMER_KEY=xxxx ./gradlew run -P env=dev 174 | 175 | ``` 176 | 177 | ```bash 178 | nvm use 179 | cd javascript 180 | yarn install 181 | yarn start 182 | ``` 183 | 184 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/administrator/Administrator.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.administrator 2 | 3 | import arrow.core.* 4 | import arrow.typeclasses.binding 5 | import com.auth0.jwt.interfaces.DecodedJWT 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.kotlin.core.json.json 8 | import io.vertx.kotlin.core.json.obj 9 | 10 | 11 | fun <T> Map<String, T>.getOption(key: String): Option<T> = this.get(key).toOption() 12 | 13 | 14 | data class Administrator(val id: String, val email: String, val isAdmin: Boolean) { 15 | fun toJson(): JsonObject = 16 | json { obj( 17 | "id" to id, 18 | "email" to email, 19 | "isAdmin" to isAdmin 20 | )} 21 | 22 | companion object { 23 | 24 | fun fromOtoroshiJwtToken(jwt: DecodedJWT): Option<Administrator> { 25 | val claims = jwt.claims 26 | return claims.getOption("name").map{ it.asString() }.map { 27 | val userId = claims.getOption("user_id").map{it.asString()}.orElse { claims.get("user_id").toOption().map { it.asString() } }.getOrElse { "NA" } 28 | val email = claims.getOption("email").map{ it.asString()}.getOrElse { "NA" } 29 | val isAdmin = claims 30 | .getOption("izanami_admin") 31 | .map{it.asString()} 32 | .flatMap { Try{ it.toBoolean() }.toOption() } 33 | .getOrElse { false } 34 | 35 | Administrator(userId, email, isAdmin) 36 | } 37 | } 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/CertificateRouter.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate 2 | 3 | import io.vertx.core.Handler 4 | import io.vertx.reactivex.ext.web.RoutingContext 5 | import fr.maif.automate.commons.* 6 | import io.vertx.kotlin.core.json.* 7 | import org.slf4j.Logger 8 | import org.slf4j.LoggerFactory 9 | import arrow.core.* 10 | import fr.maif.automate.certificate.write.* 11 | 12 | class CertificateRouter(certificates: Certificates) { 13 | 14 | companion object { 15 | val LOGGER = LoggerFactory.getLogger(CertificateRouter::class.java) as Logger 16 | } 17 | 18 | val getDomain = Handler<RoutingContext> { req -> 19 | val domain = req.pathParam("domain") 20 | certificates.allDomainsView.getDomain(domain) 21 | .subscribe ({ mayBeDomain -> 22 | when(mayBeDomain) { 23 | is Some -> req.response().endWithJson(mayBeDomain.t.json()) 24 | is None -> req.response().setStatusCode(404) .endWithJson(json { obj() }) 25 | } 26 | }, { err -> 27 | LOGGER.error("Error during process", err) 28 | req.response().end(err.message) 29 | }) 30 | } 31 | 32 | val applyCommand = Handler<RoutingContext> { req -> 33 | val bodyAsJson = req.body().asJsonObject() 34 | LOGGER.info("Certificate command {}", bodyAsJson) 35 | 36 | val command = CertificateCommand.fromJson(bodyAsJson) 37 | certificates.onCommand(command) 38 | .subscribe ({ either -> 39 | either.fold({ err -> 40 | req.response() 41 | .setStatusCode(400) 42 | .endWithJson(err) 43 | }, { _ -> 44 | req.response() 45 | .endWithJson( json{ obj("message" to "doing")}) 46 | }) 47 | }, { err -> 48 | LOGGER.error("Error during process", err) 49 | req.response().end(err.message) 50 | }) 51 | } 52 | 53 | 54 | val listCertificates = Handler<RoutingContext> { req -> 55 | certificates.allDomainsView.listDomains() 56 | .subscribe ({ json -> 57 | req.response().endWithJson(json.map { it.json() }) 58 | }, { err -> 59 | LOGGER.error("Error during process", err) 60 | req.response().end(err.message) 61 | }) 62 | } 63 | 64 | 65 | val certificatesHistory = Handler<RoutingContext> { req -> 66 | val domain = req.pathParam("domain") 67 | certificates.eventsView.events(domain) 68 | .subscribe ({ json -> 69 | req.response().endWithJson(Json.array(json)) 70 | }, { err -> 71 | LOGGER.error("Error during process", err) 72 | req.response().end(err.message) 73 | }) 74 | } 75 | 76 | 77 | 78 | val streamEvents = Handler<RoutingContext> { context -> 79 | 80 | val response = context.response() 81 | 82 | val lastId = context.request().getHeader("Last-Event-Id").toOption().map { it.toLong() } 83 | 84 | response.isChunked = true 85 | response.headers().add("Content-Type", "text/event-stream") 86 | response.headers().add("Cache-Control", "no-cache") 87 | response.headers().add("Connection", "keep-alive") 88 | response.statusCode = 200 89 | response.write("") 90 | certificates.eventsView.eventsStream().subscribe({ (id, evt) -> 91 | context.response().write("id: $id\ndata: ${evt.encode()}\n\n") 92 | }, { 93 | //LOGGER.error("Error during sse", e) 94 | //context.response().end() 95 | }) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/Certificates.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate 2 | 3 | import arrow.core.Either 4 | import fr.maif.automate.certificate.eventhandler.EventToCommandAdapter 5 | import fr.maif.automate.certificate.eventhandler.TeamsEventHandler 6 | import fr.maif.automate.commons.LetsAutomateConfig 7 | import fr.maif.automate.commons.eventsourcing.PostgresEventStore 8 | import fr.maif.automate.letsencrypt.LetSEncryptManager 9 | import fr.maif.automate.publisher.CertificatePublisher 10 | import fr.maif.automate.certificate.scheduler.CertificateRenewer 11 | import fr.maif.automate.certificate.views.AllDomainView 12 | import fr.maif.automate.certificate.views.EventsView 13 | import fr.maif.automate.certificate.write.* 14 | import fr.maif.automate.commons.Error 15 | import io.reactivex.Single 16 | import io.vertx.reactivex.ext.sql.SQLClient 17 | import io.vertx.reactivex.ext.web.client.WebClient 18 | import org.slf4j.Logger 19 | import org.slf4j.LoggerFactory 20 | 21 | class Certificates( 22 | letsAutomateConfig: LetsAutomateConfig, 23 | letSEncryptManager: LetSEncryptManager, 24 | client: WebClient, 25 | certificatePublisher: CertificatePublisher, 26 | postgresClient: SQLClient 27 | ) { 28 | companion object { 29 | val LOGGER: Logger = LoggerFactory.getLogger(Certificates::class.java) 30 | } 31 | 32 | private val eventStore = PostgresEventStore("certificate_events", "certificate_events_offsets", postgresClient) 33 | private val eventReader = CertificateEventReader() 34 | private val certificateEventStore = CertificateEventStore("certificate", letSEncryptManager, certificatePublisher, eventStore, eventReader) 35 | private val certificateRenewer = CertificateRenewer(letsAutomateConfig.certificates.pollingInterval, this) 36 | private val eventToCommandAdapter = EventToCommandAdapter(eventStore, this, eventReader) 37 | private val teamsEventHandler = TeamsEventHandler(letsAutomateConfig.env, letsAutomateConfig.teams, client, eventStore, eventReader) 38 | val allDomainsView = AllDomainView(eventStore, eventReader) 39 | val eventsView = EventsView(eventStore, eventReader) 40 | 41 | init { 42 | certificateRenewer.startScheduler() 43 | eventToCommandAdapter.startAdapter() 44 | teamsEventHandler.startTeamsHandler() 45 | } 46 | 47 | fun state(): Single<State.AllCertificates> = certificateEventStore.state() 48 | 49 | fun onCommand(command: CertificateCommand): Single<Either<Error, CertificateEvent>> = certificateEventStore.onCommand(command) 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/eventhandler/EventToCommandAdapter.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.eventhandler 2 | 3 | import arrow.core.None 4 | import arrow.core.Some 5 | import arrow.core.toOption 6 | import fr.maif.automate.certificate.Certificates 7 | import fr.maif.automate.certificate.write.* 8 | import fr.maif.automate.commons.eventsourcing.EventReader 9 | import fr.maif.automate.commons.eventsourcing.EventStore 10 | import io.reactivex.Observable 11 | import io.reactivex.disposables.Disposable 12 | import org.slf4j.Logger 13 | import org.slf4j.LoggerFactory 14 | import java.util.concurrent.atomic.AtomicReference 15 | 16 | class EventToCommandAdapter(private val eventStore: EventStore, private val certificates: Certificates, private val eventReader: EventReader<CertificateEvent>) { 17 | companion object { 18 | val LOGGER = LoggerFactory.getLogger(EventToCommandAdapter::class.java) as Logger 19 | const val GROUP_ID = "EventToCommandAdapter" 20 | val ref = AtomicReference<Disposable>(null) 21 | } 22 | 23 | fun startAdapter() { 24 | LOGGER.info("Starting event to command adapter") 25 | val disposable = adaptaterStream() 26 | .retry(3) 27 | .subscribe({}, { e -> 28 | LOGGER.error("Error consuming command stream, going to restart", e) 29 | }) 30 | ref.set(disposable) 31 | } 32 | 33 | private fun adaptaterStream(): Observable<Unit> { 34 | return eventStore 35 | .eventStreamByGroupId(GROUP_ID) 36 | .map { enveloppe -> 37 | val event = eventReader.read(enveloppe) 38 | 39 | val commands = when (event) { 40 | is CertificateCreated -> { 41 | val (domain, subdomain, wildcard) = event 42 | enveloppe.sequence to OrderCertificate(domain, subdomain, wildcard).toOption() 43 | } 44 | is CertificateOrdered -> { 45 | val (domain, subdomain) = event 46 | enveloppe.sequence to PublishCertificate(domain, subdomain).toOption() 47 | } 48 | is CertificateReOrderedStarted -> { 49 | val (domain, subdomain, wildcard) = event 50 | enveloppe.sequence to RenewCertificate(domain, subdomain, wildcard).toOption() 51 | } 52 | is CertificateReOrdered -> { 53 | val (domain, subdomain) = event 54 | enveloppe.sequence to PublishCertificate(domain, subdomain).toOption() 55 | } 56 | else -> enveloppe.sequence to None 57 | } 58 | LOGGER.info("Event adapt $event to $commands") 59 | commands 60 | } 61 | .flatMap { (sequence, mayBeCommand) -> 62 | when (mayBeCommand) { 63 | is Some -> { 64 | certificates.onCommand(mayBeCommand.t) 65 | .flatMap { 66 | LOGGER.info("Command success, committing from group id $GROUP_ID and sequence_num $sequence") 67 | eventStore.commit(GROUP_ID, sequence) 68 | }.toObservable() 69 | } 70 | 71 | is None -> { 72 | LOGGER.info("Empty Command, committing from group id $GROUP_ID and sequence_num $sequence") 73 | eventStore.commit(GROUP_ID, sequence).toObservable() 74 | } 75 | } 76 | } 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/eventhandler/TeamsEventHandler.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.eventhandler 2 | 3 | import arrow.core.getOrElse 4 | import fr.maif.automate.certificate.write.* 5 | import fr.maif.automate.commons.Dev 6 | import fr.maif.automate.commons.Env 7 | import fr.maif.automate.commons.TeamsConfig 8 | import fr.maif.automate.commons.eventsourcing.EventReader 9 | import fr.maif.automate.commons.eventsourcing.EventStore 10 | import io.reactivex.Single 11 | import io.vertx.kotlin.core.json.json 12 | import io.vertx.kotlin.core.json.obj 13 | import io.vertx.reactivex.ext.web.client.WebClient 14 | import org.slf4j.Logger 15 | import org.slf4j.LoggerFactory 16 | 17 | 18 | class TeamsEventHandler(val env: Env, val config: TeamsConfig, val client: WebClient, val eventStore: EventStore, private val eventReader: EventReader<CertificateEvent>) { 19 | 20 | companion object { 21 | val LOGGER = LoggerFactory.getLogger(TeamsEventHandler::class.java) as Logger 22 | val GROUP_ID = TeamsEventHandler::class.java.simpleName 23 | } 24 | 25 | fun startTeamsHandler() { 26 | LOGGER.info("Starting Teams event handler") 27 | eventStore 28 | .eventStreamByGroupId(GROUP_ID) 29 | .map { enveloppe -> enveloppe.sequence to eventReader.read(enveloppe) } 30 | .map { (sequence, event) -> 31 | when(event) { 32 | is CertificateCreated -> { 33 | sequence to "A Certificate is asked ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain} with wildcard=${event.wildcard}" 34 | } 35 | is CertificateOrdered -> { 36 | sequence to "A Certificate order succeed for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain}" 37 | } 38 | is CertificateOrderFailure -> { 39 | sequence to "A Certificate order failed for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain}" 40 | } 41 | is CertificateReOrderedStarted -> { 42 | sequence to "A Certificate renew was asked for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain}" 43 | } 44 | is CertificateReOrdered -> { 45 | sequence to "A Certificate renew succeed for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain}" 46 | } 47 | is CertificateReOrderFailure -> { 48 | sequence to "A Certificate renew failed for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain}" 49 | } 50 | is CertificatePublished -> { 51 | sequence to "The Certificate for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain} has been published" 52 | } 53 | is CertificatePublishFailure -> { 54 | sequence to "The Certificate for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain} fail to publish" 55 | } 56 | is CertificateDeleted -> { 57 | sequence to "The Certificate for ${event.subdomain.map { "$it." }.getOrElse { "" }}${event.domain} has been deleted" 58 | } 59 | } 60 | } 61 | .subscribe { (sequence, message) -> 62 | sendMessage(sequence,message).subscribe() 63 | } 64 | } 65 | 66 | fun sendMessage(sequence: Long, message: String): Single<Unit> { 67 | return when(env) { 68 | is Dev -> Single.just(Unit) 69 | else -> { 70 | LOGGER.info("""Sending "$message" to Teams ${config.url}""") 71 | 72 | client 73 | .postAbs("${config.url}") 74 | .putHeader("Content-Type", "application/json") 75 | .rxSendJsonObject(json { 76 | obj( 77 | "text" to message 78 | ) 79 | }) 80 | .doFinally { 81 | LOGGER.info("Commiting for $sequence for $GROUP_ID") 82 | eventStore.commit(GROUP_ID, sequence).subscribe() 83 | } 84 | .doOnSuccess { res -> 85 | LOGGER.info("Message has been sent to Teams: ${res.statusCode()} with body ${res.bodyAsString()}") 86 | } 87 | .doOnError { e -> 88 | LOGGER.error("Error sending to Teams", e) 89 | } 90 | .map { _ -> Unit } 91 | } 92 | } 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/scheduler/CertificateRenewer.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.scheduler 2 | 3 | import arrow.core.Either 4 | import fr.maif.automate.certificate.Certificates 5 | import fr.maif.automate.certificate.write.CertificateEvent 6 | import fr.maif.automate.certificate.write.RenewCertificate 7 | import fr.maif.automate.certificate.write.State 8 | import fr.maif.automate.commons.Error 9 | import fr.maif.automate.commons.Interval 10 | import io.reactivex.Observable 11 | import org.slf4j.Logger 12 | import org.slf4j.LoggerFactory 13 | import java.time.LocalDateTime 14 | 15 | 16 | class CertificateRenewer( 17 | private val pollingInterval: Interval, 18 | private val certificates: Certificates 19 | ) { 20 | 21 | 22 | companion object { 23 | val LOGGER: Logger = LoggerFactory.getLogger(CertificateRenewer::class.java) 24 | 25 | fun isCertificateExpired(c: State.CertificateState, d: LocalDateTime = LocalDateTime.now()): Boolean = 26 | c.certificate?.expire?.isBefore(d.plusDays(30)) ?: false 27 | } 28 | 29 | fun startScheduler() { 30 | Observable.interval(pollingInterval.period, pollingInterval.unit) 31 | .flatMap { 32 | LOGGER.info("Looking for certificate to renew") 33 | findDomainToRenew() 34 | .onErrorReturn { emptyList() } 35 | .doOnNext {domains -> 36 | if (domains.isNotEmpty()) { 37 | LOGGER.info("Found ${domains.map { it.domain }} to renew") 38 | } 39 | } 40 | .concatMapIterable { it } 41 | } 42 | .flatMap { domain -> 43 | if (domain.wildcard != null && domain.domain != null) { 44 | certificates.onCommand(RenewCertificate(domain.domain, domain.subdomain, domain.wildcard)) 45 | .toObservable() 46 | .onErrorReturn { e -> 47 | LOGGER.error("Error while renewing certificate for ${domain.domain}", e) 48 | Either.Left(Error(e.message)) as Either<Error, CertificateEvent> 49 | } 50 | } else{ 51 | Observable.just(Either.Left(Error("Domain $domain invalid")) as Either<Error, CertificateEvent>) 52 | } 53 | 54 | } 55 | .subscribe({ res -> 56 | when(res) { 57 | is Either.Right -> 58 | LOGGER.debug("Renew ok for ${res.b}") 59 | is Either.Left -> 60 | LOGGER.error("Error while renewing certificate: ${res.a}") 61 | } 62 | }, { e -> 63 | LOGGER.error("Error while renewing certificate", e) 64 | }) 65 | } 66 | 67 | private fun findDomainToRenew() = 68 | certificates 69 | .state() 70 | .toObservable() 71 | .map { all -> 72 | val toRenew = all.list().filter { isCertificateExpired(it) } 73 | LOGGER.info("""Finding certificates to renew in ${all.list().map { c -> (c.domain to c.subdomain) to c.certificate?.expire }}. 74 | | -Now is ${LocalDateTime.now()}\n 75 | | -expire: ${LocalDateTime.now().plusDays(30)} 76 | | Found $toRenew 77 | | """.trimMargin()) 78 | toRenew 79 | } 80 | 81 | 82 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/views/AllDomainView.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.views 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import arrow.core.getOrElse 6 | import arrow.core.toOption 7 | import fr.maif.automate.certificate.write.* 8 | import fr.maif.automate.commons.eventsourcing.EventReader 9 | import fr.maif.automate.commons.eventsourcing.EventStore 10 | import io.reactivex.Single 11 | import io.vertx.core.json.JsonObject 12 | import io.vertx.kotlin.core.json.json 13 | import io.vertx.kotlin.core.json.obj 14 | import org.slf4j.Logger 15 | import org.slf4j.LoggerFactory 16 | import java.time.LocalDateTime 17 | import java.time.format.DateTimeFormatter 18 | import java.util.concurrent.ConcurrentHashMap 19 | 20 | data class CertificateError(val type: String, val cause: String) { 21 | fun json(): JsonObject = json {obj( 22 | "type" to type, 23 | "cause" to cause 24 | )} 25 | } 26 | 27 | data class CertificateDomainResume( 28 | val subdomain: Option<String>, 29 | val wildcard: Boolean = false, 30 | val certificate: Option<CertificateResume> = None, 31 | val publication: Option<PublicationResume> = None, 32 | val error: Option<CertificateError> = None 33 | ) { 34 | fun json(): JsonObject = json {obj(listOf( 35 | subdomain.map { "subdomain" to it }, 36 | ("wildcard" to wildcard).toOption(), 37 | certificate.map { "certificate" to it.json() }, 38 | publication.map { "publication" to it.json() }, 39 | error.map { "error" to it.json() } 40 | ).flatMap { it.toList() })} 41 | 42 | } 43 | 44 | data class DomainResume( 45 | val domain: String, 46 | val certificates: ConcurrentHashMap<String, CertificateDomainResume> = ConcurrentHashMap()) { 47 | 48 | private fun buildKey(subdomain: Option<String>): String { 49 | return "$domain-${subdomain.getOrElse { "na" }}" 50 | } 51 | 52 | fun json(): JsonObject = json {obj( 53 | "domain" to domain, 54 | "certificates" to certificates.map { it.value.json() } 55 | )} 56 | 57 | fun updateCertificate(subdomain: Option<String>, f: (CertificateDomainResume) -> CertificateDomainResume) { 58 | val updated = f(certificates.getOrPut(buildKey(subdomain)){CertificateDomainResume(subdomain)}) 59 | certificates.put(buildKey(subdomain), updated) 60 | } 61 | 62 | fun deleteCertificate(subdomain: Option<String>) { 63 | certificates.remove(buildKey(subdomain)) 64 | } 65 | } 66 | 67 | data class CertificateResume(val expire: LocalDateTime) { 68 | fun json(): JsonObject = json {obj("expire" to DateTimeFormatter.ISO_DATE_TIME.format(expire))} 69 | } 70 | data class PublicationResume(val publishDate: LocalDateTime) { 71 | fun json(): JsonObject = json {obj("publishDate" to DateTimeFormatter.ISO_DATE_TIME.format(publishDate))} 72 | } 73 | 74 | class AllDomainView( 75 | private val eventStore: EventStore, 76 | private val eventReader: EventReader<CertificateEvent> = CertificateEventReader() 77 | ) { 78 | 79 | private val datas = ConcurrentHashMap<String, DomainResume>() 80 | 81 | companion object { 82 | private val LOGGER: Logger = LoggerFactory.getLogger(AllDomainView::class.java) 83 | } 84 | 85 | private fun update(domain: String, mayBeSubdomain: Option<String>, f: (CertificateDomainResume) -> CertificateDomainResume) { 86 | val domainResume = datas.getOrPut(domain){ DomainResume(domain) } 87 | domainResume.updateCertificate(mayBeSubdomain, f) 88 | } 89 | 90 | init { 91 | reload() 92 | } 93 | 94 | private fun reload() { 95 | eventStore 96 | .loadEvents() 97 | .concatWith(eventStore.eventStream()) 98 | .map { eventReader.read(it) } 99 | .subscribe { event -> 100 | when (event) { 101 | is CertificateCreated -> { 102 | val (domain, subdomain, wildcard) = event 103 | update(domain, subdomain) { it.copy(wildcard = wildcard) } 104 | } 105 | is CertificateOrdered -> { 106 | val (domain, subdomain, _, _, _, certificate) = event 107 | update(domain, subdomain) { it.copy(certificate = CertificateResume(expire = certificate.expire).toOption(), error = None) } 108 | } 109 | is CertificateOrderFailure -> { 110 | val (domain, subdomain, cause) = event 111 | update(domain, subdomain) { it.copy(error = CertificateError(CertificateOrderFailure::class.java.simpleName, cause).toOption()) } 112 | } 113 | is CertificateReOrdered -> { 114 | val (domain, subdomain, _, _, _, certificate) = event 115 | update(domain, subdomain) { it.copy(certificate = CertificateResume(expire = certificate.expire).toOption(), error = None) } 116 | } 117 | is CertificateReOrderFailure -> { 118 | val (domain, subdomain, cause) = event 119 | update(domain, subdomain) { it.copy(error = CertificateError(CertificateReOrderFailure::class.java.simpleName, cause).toOption()) } 120 | } 121 | is CertificatePublished -> { 122 | val (domain, subdomain, published) = event 123 | update(domain, subdomain) { it.copy(publication = PublicationResume(published).toOption(), error = None) } 124 | } 125 | is CertificatePublishFailure -> { 126 | val (domain, subdomain, cause) = event 127 | update(domain, subdomain) { it.copy(error = CertificateError(CertificatePublishFailure::class.java.simpleName, cause).toOption()) } 128 | } 129 | is CertificateDeleted -> { 130 | val (domain, subdomain) = event 131 | datas[domain].toOption().toList().forEach { it.deleteCertificate(subdomain) } 132 | } 133 | is CertificateReOrderedStarted -> { 134 | LOGGER.info("Scheduler CertificateRenewer started") 135 | } 136 | else -> { 137 | LOGGER.warn("Unmanaged event: ${event.type()}") 138 | } 139 | } 140 | } 141 | } 142 | 143 | 144 | fun listDomains(): Single<List<DomainResume>> { 145 | return Single.just(datas.values.toList()) 146 | } 147 | 148 | fun getDomain(name: String): Single<Option<DomainResume>> { 149 | return Single.just(datas.get(name).toOption()) 150 | } 151 | 152 | 153 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/views/EventsView.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.views 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import arrow.core.getOrElse 6 | import fr.maif.automate.certificate.write.CertificateEvent 7 | import fr.maif.automate.commons.eventsourcing.EventReader 8 | import fr.maif.automate.commons.eventsourcing.EventStore 9 | import io.reactivex.Observable 10 | import io.reactivex.Single 11 | import io.vertx.core.json.JsonObject 12 | import io.vertx.kotlin.core.json.json 13 | import io.vertx.kotlin.core.json.obj 14 | 15 | class EventsView(private val eventStore: EventStore, private val eventReader: EventReader<CertificateEvent>) { 16 | 17 | fun events(domain: String, id: Option<Long> = None): Single<List<JsonObject>> { 18 | return eventStore.loadEventsById(domain, id.getOrElse { 0 }) 19 | .map { Triple(eventReader.read(it), it.eventType, it.sequence) } 20 | .map { p -> 21 | eventToJson(p) 22 | } 23 | .toList() 24 | 25 | } 26 | 27 | fun eventsStream(): Observable<Pair<Long, JsonObject>> { 28 | return eventStore 29 | .eventStream() 30 | .map { Triple(eventReader.read(it), it.eventType, it.sequence) } 31 | .map { p -> 32 | p.third to eventToJson(p) 33 | } 34 | } 35 | 36 | private fun eventToJson(p: Triple<CertificateEvent, String, Long>): JsonObject { 37 | val (event, t, seq) = p 38 | return json { 39 | obj( 40 | "sequence" to seq, 41 | "type" to t, 42 | "event" to event.exposedJson() 43 | ) 44 | } 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/write/commands.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.write 2 | 3 | import arrow.core.* 4 | import fr.maif.automate.commons.Error 5 | import fr.maif.automate.commons.eventsourcing.Command 6 | import io.reactivex.Single 7 | import io.vertx.core.json.JsonObject 8 | 9 | sealed class CertificateCommand : Command { 10 | companion object { 11 | fun fromJson(json: JsonObject): CertificateCommand = 12 | when(json.getString("type")) { 13 | nameOf(CreateCertificate::class) -> CreateCertificate.fromJson(json.getJsonObject("command")) 14 | nameOf(OrderCertificate::class) -> OrderCertificate.fromJson(json.getJsonObject("command")) 15 | nameOf(StartRenewCertificate::class) -> StartRenewCertificate.fromJson(json.getJsonObject("command")) 16 | nameOf(RenewCertificate::class) -> RenewCertificate.fromJson(json.getJsonObject("command")) 17 | nameOf(PublishCertificate::class) -> PublishCertificate.fromJson(json.getJsonObject("command")) 18 | nameOf(DeleteCertificate::class) -> DeleteCertificate.fromJson(json.getJsonObject("command")) 19 | else -> throw IllegalArgumentException("Unknown command $json") 20 | } 21 | 22 | private val regex = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+".toRegex() 23 | 24 | fun validateSubdomain(domain: String, subdomain: Option<String>): Either<Error, Option<String>> = subdomain 25 | .map {s -> 26 | Either.cond(!regex.matches(s), { subdomain }, { Error("Subdomain should end with ${domain}") }) 27 | } 28 | .getOrElse { subdomain.right() } 29 | } 30 | } 31 | data class CreateCertificate(val domain: String, val subdomain: Option<String>, val wildcard: Boolean): CertificateCommand() { 32 | companion object { 33 | fun fromJson(json: JsonObject): CertificateCommand = 34 | CreateCertificate( 35 | json.getString("domain"), 36 | json.getString("subdomain").toOption().filter { it.isNotBlank() }, 37 | json.getBoolean("wildcard") 38 | ) 39 | 40 | fun validate(command: CreateCertificate, state: State.AllCertificates): Either<Error, CreateCertificate> { 41 | val (domain, subdomain) = command 42 | return Either.cond(state.get(State.Key(domain, subdomain)).isEmpty(), { command }, { Error("Subdomain already exist") }) 43 | .flatMap { _ -> 44 | validateSubdomain(domain, command.subdomain).map { _ -> command} 45 | } 46 | } 47 | 48 | 49 | } 50 | } 51 | data class OrderCertificate(val domain: String, val subdomain: Option<String>, val wildcard: Boolean): CertificateCommand() { 52 | companion object { 53 | fun fromJson(json: JsonObject): CertificateCommand = 54 | OrderCertificate( 55 | json.getString("domain"), 56 | json.getString("subdomain").toOption().filter { it.isNotBlank() }, 57 | json.getBoolean("wildcard") 58 | ) 59 | 60 | fun validate(command: OrderCertificate, state: State.AllCertificates): Either<Error, OrderCertificate> { 61 | val (domain, subdomain) = command 62 | return Either.cond(state.get(State.Key(domain, subdomain)).isDefined(), { command }, { Error("Domain $domain should be created") }) 63 | .flatMap { _ -> 64 | validateSubdomain(domain, command.subdomain).map { _ -> command} 65 | } 66 | } 67 | } 68 | } 69 | data class StartRenewCertificate(val domain: String, val subdomain: Option<String>, val wildcard: Boolean): CertificateCommand() { 70 | companion object { 71 | 72 | fun fromJson(json: JsonObject): CertificateCommand = 73 | StartRenewCertificate( 74 | json.getString("domain"), 75 | json.getString("subdomain").toOption().filter { it.isNotBlank() }, 76 | json.getBoolean("wildcard") 77 | ) 78 | 79 | fun validate(command: StartRenewCertificate, state: State.AllCertificates): Either<Error, StartRenewCertificate> { 80 | val (domain, subdomain) = command 81 | return Either.cond(state.get(State.Key(domain, subdomain)).isDefined(), { command }, { Error("Domain $domain should be created") }) 82 | .flatMap { _ -> 83 | validateSubdomain(domain, command.subdomain).map { _ -> command} 84 | } 85 | } 86 | } 87 | } 88 | data class RenewCertificate(val domain: String, val subdomain: Option<String>, val wildcard: Boolean): CertificateCommand() { 89 | companion object { 90 | 91 | fun fromJson(json: JsonObject): CertificateCommand = 92 | RenewCertificate( 93 | json.getString("domain"), 94 | json.getString("subdomain").toOption().filter { it.isNotBlank() }, 95 | json.getBoolean("wildcard") 96 | ) 97 | 98 | fun validate(command: RenewCertificate, state: State.AllCertificates): Either<Error, RenewCertificate> { 99 | val (domain, subdomain) = command 100 | return state.get(State.Key(domain, subdomain)).toEither { Error("Domain $domain should be created") } 101 | .flatMap { 102 | validateSubdomain(domain, command.subdomain).map { _ -> command} 103 | // .flatMap { _ -> 104 | // Either.cond(!s.reordered, {command}, {Error("Invalid state, certificate should be reordered first")}) 105 | // } 106 | } 107 | } 108 | } 109 | } 110 | data class PublishCertificate(val domain: String, val subdomain: Option<String>): CertificateCommand() { 111 | companion object { 112 | fun fromJson(json: JsonObject): CertificateCommand = 113 | PublishCertificate( 114 | json.getString("domain"), 115 | json.getString("subdomain").toOption().filter { it.isNotBlank() } 116 | ) 117 | 118 | fun validate(command: PublishCertificate, state: State.AllCertificates): Either<Error, PublishCertificate> { 119 | val (domain, subdomain) = command 120 | return state.get(State.Key(domain, subdomain)).toEither { Error("Domain $domain should be created") } 121 | .flatMap { c -> 122 | Either.cond(!(c.certificate == null || 123 | c.csr == null || 124 | c.domain == null || 125 | c.privateKey == null || 126 | c.wildcard == null), { command }, { Error("Domain $domain should be created") }) 127 | } 128 | } 129 | 130 | } 131 | } 132 | data class DeleteCertificate(val domain: String, val subdomain: Option<String>): CertificateCommand() { 133 | companion object { 134 | fun fromJson(json: JsonObject): CertificateCommand = 135 | DeleteCertificate( 136 | json.getString("domain"), 137 | json.getString("subdomain").toOption().filter { it.isNotBlank() } 138 | ) 139 | 140 | fun validate(command: DeleteCertificate, state: State.AllCertificates): Either<Error, DeleteCertificate> { 141 | val (domain, subdomain) = command 142 | return state.get(State.Key(domain, subdomain)).toEither { Error("Domain $domain should be created") } 143 | .map { _ -> command } 144 | } 145 | 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/certificate/write/state.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.write 2 | 3 | import arrow.core.None 4 | import arrow.core.Option 5 | import arrow.core.toOption 6 | import arrow.syntax.collections.flatten 7 | import fr.maif.automate.letsencrypt.Certificate 8 | import io.vertx.core.json.JsonObject 9 | import io.vertx.kotlin.core.json.json 10 | import io.vertx.kotlin.core.json.obj 11 | import java.security.KeyPair 12 | import java.time.LocalDateTime 13 | import java.time.format.DateTimeFormatter 14 | import java.util.concurrent.ConcurrentHashMap 15 | 16 | 17 | object State { 18 | 19 | data class Key(val domain: String, val subdomain: Option<String>) 20 | 21 | data class AllCertificates(val data: ConcurrentHashMap<Key, CertificateState> = ConcurrentHashMap()) { 22 | fun update(id: Key, cb: (CertificateState) -> CertificateState): AllCertificates { 23 | val current = data.getOrDefault(id, CertificateState()) 24 | data.put(id, cb(current)) 25 | return this.copy(data = data) 26 | } 27 | 28 | fun get(id: Key): Option<CertificateState> = data[id].toOption() 29 | 30 | fun list(): List<CertificateState> = data.toList().map { it.second } 31 | 32 | fun delete(id: Key): AllCertificates { 33 | data.remove(id) 34 | return this.copy(data = data) 35 | } 36 | } 37 | 38 | data class CertificateState(val domain: String? = null, val subdomain: Option<String> = None, val wildcard: Boolean? = null, val reordered: Boolean = false, val privateKey: KeyPair? = null, val csr: String? = null, val certificate: Certificate? = null, val publishedDate: LocalDateTime? = null) { 39 | fun exposedJson(): JsonObject = json { 40 | obj(listOf( 41 | domain.toOption().map { "domain" to it }, 42 | subdomain.map { "subdomain" to it }, 43 | wildcard.toOption().map { "wildcard" to it }, 44 | certificate.toOption().flatMap { it.expire.toOption() }.map { "expire" to DateTimeFormatter.ISO_DATE_TIME.format(it) }, 45 | publishedDate.toOption().map { "publishedDate" to DateTimeFormatter.ISO_DATE_TIME.format(it) } 46 | ).flatten()) 47 | } 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/CertificateUtils.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | import org.shredzone.acme4j.toolbox.AcmeUtils 4 | import org.shredzone.acme4j.util.KeyPairUtils 5 | import java.io.ByteArrayInputStream 6 | import java.io.InputStream 7 | import java.io.StringReader 8 | import java.io.StringWriter 9 | import java.security.KeyPair 10 | import java.security.cert.CertificateFactory 11 | import java.security.cert.X509Certificate 12 | 13 | 14 | fun KeyPair.stringify(): String { 15 | val stringWriter = StringWriter() 16 | KeyPairUtils.writeKeyPair(this, stringWriter) 17 | 18 | return stringWriter.toString() 19 | } 20 | 21 | fun readKeyPair(string: String): KeyPair = KeyPairUtils.readKeyPair(StringReader(string)) 22 | 23 | fun X509Certificate.stringify(): String = x509ToString(this) 24 | 25 | 26 | fun x509FromString(certEntry: String): X509Certificate { 27 | var `in`: InputStream? = null 28 | try { 29 | val certEntryBytes = certEntry.toByteArray() 30 | `in` = ByteArrayInputStream(certEntryBytes) 31 | val certFactory = CertificateFactory.getInstance("X.509") 32 | 33 | return certFactory.generateCertificate(`in`) as X509Certificate 34 | } catch (ex: Throwable) { 35 | throw ex 36 | } finally { 37 | if (`in` != null) { 38 | `in`.close() 39 | } 40 | } 41 | } 42 | 43 | fun x509ToString(cert: X509Certificate): String { 44 | val writer = StringWriter() 45 | AcmeUtils.writeToPem(cert.encoded, AcmeUtils.PemLabel.CERTIFICATE, writer) 46 | return writer.toString() 47 | } 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/Configuration.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | import arrow.core.Option 4 | import com.typesafe.config.Config 5 | import com.typesafe.config.ConfigObject 6 | import java.util.concurrent.TimeUnit 7 | 8 | 9 | data class LetsAutomateConfig( 10 | val http: HttpConfig, 11 | val env: Env, 12 | val ovh: Ovh, 13 | val logout: String, 14 | val certificates: CertificatesConfig, 15 | val letSEncrypt: LetSEncryptConfig, 16 | val postgresConfig: PostgresConfig, 17 | val clevercloud: CleverConfig, 18 | val teams: TeamsConfig, 19 | val otoroshi: OtoroshiConfig) { 20 | companion object { 21 | fun load(config: Config): LetsAutomateConfig { 22 | return LetsAutomateConfig( 23 | http = HttpConfig.load(config), 24 | env = Env.load(config), 25 | ovh = Ovh.load(config), 26 | logout = config.getString("logout"), 27 | certificates = CertificatesConfig.load(config), 28 | letSEncrypt = LetSEncryptConfig.load(config), 29 | postgresConfig = PostgresConfig.load(config), 30 | clevercloud = CleverConfig.load(config), 31 | teams = TeamsConfig.load(config), 32 | otoroshi = OtoroshiConfig.load(config) 33 | ) 34 | } 35 | } 36 | } 37 | 38 | data class HttpConfig(val host: String, val port: Int) { 39 | companion object { 40 | fun load(config: Config): HttpConfig = 41 | HttpConfig( 42 | config.getString("http.host"), 43 | config.getInt("http.port") 44 | ) 45 | } 46 | } 47 | 48 | data class CertificatesConfig(val pollingInterval: Interval) { 49 | companion object { 50 | fun load(config: Config): CertificatesConfig = 51 | CertificatesConfig( 52 | pollingInterval = Interval.fromJson(config.getObject("certificates.pollingInterval")) 53 | ) 54 | } 55 | } 56 | 57 | data class TeamsConfig( 58 | val url: String 59 | ) { 60 | companion object { 61 | fun load(config: Config): TeamsConfig = 62 | TeamsConfig( 63 | url = config.getString("teams.url") 64 | ) 65 | } 66 | } 67 | 68 | data class Ovh( 69 | val applicationKey: String, 70 | val applicationSecret: String, 71 | val consumerKey: String, 72 | val host: String 73 | ) { 74 | companion object { 75 | fun load(config: Config): Ovh = 76 | Ovh( 77 | applicationKey = config.getString("ovh.applicationKey"), 78 | applicationSecret = config.getString("ovh.applicationSecret"), 79 | consumerKey = config.getString("ovh.consumerKey"), 80 | host = config.getString("ovh.host") 81 | ) 82 | } 83 | } 84 | 85 | data class Interval(val period: Long, val unit: TimeUnit) { 86 | companion object { 87 | fun fromJson(obj: ConfigObject): Interval { 88 | val conf = obj.toConfig() 89 | return Interval( 90 | conf.getLong("period"), 91 | TimeUnit.valueOf(conf.getString("unit")) 92 | ) 93 | } 94 | } 95 | } 96 | 97 | data class LetSEncryptConfig(val server: String, val accountId: String) { 98 | companion object { 99 | fun load(config: Config): LetSEncryptConfig = 100 | LetSEncryptConfig( 101 | server = config.getString("letsencrypt.server"), 102 | accountId = config.getString("letsencrypt.accountId") 103 | ) 104 | } 105 | } 106 | 107 | data class PostgresConfig(val host: String, val port: Int, val database: String, val username: Option<String>, val password: Option<String>, val maxPoolSize: Int) { 108 | companion object { 109 | fun load(config: Config): PostgresConfig = 110 | PostgresConfig( 111 | host = config.getString("postgres.host"), 112 | port = config.getInt("postgres.port"), 113 | database = config.getString("postgres.database"), 114 | username = Option(config.getString("postgres.username")), 115 | password = Option(config.getString("postgres.password")), 116 | maxPoolSize = config.getInt("postgres.maxPoolSize") 117 | ) 118 | } 119 | } 120 | 121 | data class CleverConfig(val host: String, val consumerKey: String, val consumerSecret: String, val clientToken: String, val clientSecret: String) { 122 | companion object { 123 | fun load(config: Config): CleverConfig = 124 | CleverConfig( 125 | host = config.getString("clevercloud.host"), 126 | consumerKey = config.getString("clevercloud.consumerKey"), 127 | consumerSecret = config.getString("clevercloud.consumerSecret"), 128 | clientToken = config.getString("clevercloud.clientToken"), 129 | clientSecret = config.getString("clevercloud.clientSecret") 130 | ) 131 | } 132 | } 133 | 134 | 135 | sealed class Env { 136 | companion object { 137 | fun load(config: Config): Env = 138 | when (config.getString("env")) { 139 | "dev" -> Dev 140 | "prod" -> Prod 141 | else -> throw IllegalArgumentException("Env type unknown ${config.getString("env")}") 142 | } 143 | } 144 | } 145 | 146 | object Dev : Env() 147 | object Prod : Env() 148 | 149 | 150 | data class OtoroshiConfig( 151 | val headerRequestId: String, 152 | val headerGatewayStateResp: String, 153 | val headerGatewayState: String, 154 | val headerClaim: String, 155 | val sharedKey: String, 156 | val issuer: String, 157 | val providerMonitoringHeader: String) { 158 | companion object { 159 | fun load(config: Config): OtoroshiConfig = 160 | OtoroshiConfig( 161 | config.getString("otoroshi.headerRequestId"), 162 | config.getString("otoroshi.headerGatewayStateResp"), 163 | config.getString("otoroshi.headerGatewayState"), 164 | config.getString("otoroshi.headerClaim"), 165 | config.getString("otoroshi.sharedKey"), 166 | config.getString("otoroshi.issuer"), 167 | config.getString("otoroshi.providerMonitoringHeader") 168 | ) 169 | } 170 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/Error.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | data class Error(val message: String) -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/HashUtils.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | import java.security.MessageDigest 4 | 5 | object HashUtils { 6 | fun sha512(input: String) = hashString("SHA-512", input) 7 | 8 | fun sha256(input: String) = hashString("SHA-256", input) 9 | 10 | fun sha1(input: String) = hashString("SHA-1", input) 11 | 12 | /** 13 | * Supported algorithms on Android: 14 | * 15 | * Algorithm Supported API Levels 16 | * MD5 1+ 17 | * SHA-1 1+ 18 | * SHA-224 1-8,22+ 19 | * SHA-256 1+ 20 | * SHA-384 1+ 21 | * SHA-512 1+ 22 | */ 23 | private fun hashString(type: String, input: String): String { 24 | val HEX_CHARS = "0123456789abcdef" 25 | val bytes = MessageDigest 26 | .getInstance(type) 27 | .digest(input.toByteArray()) 28 | val result = StringBuilder(bytes.size * 2) 29 | 30 | bytes.forEach { 31 | val i = it.toInt() 32 | result.append(HEX_CHARS[i shr 4 and 0x0f]) 33 | result.append(HEX_CHARS[i and 0x0f]) 34 | } 35 | 36 | return result.toString() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/Http.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | import io.vertx.core.json.Json 4 | import io.vertx.reactivex.core.http.HttpServerResponse 5 | 6 | 7 | /** 8 | * Extension to the HTTP response to output JSON objects. 9 | */ 10 | fun HttpServerResponse.endWithJson(obj: Any) { 11 | this.putHeader("Content-Type", "application/json; charset=utf-8").end(Json.encodePrettily(obj)) 12 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/eventsourcing/InMemoryEventStore.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons.eventsourcing 2 | 3 | import arrow.core.Option 4 | import io.reactivex.Observable 5 | import io.reactivex.Single 6 | import io.reactivex.subjects.PublishSubject 7 | import java.util.concurrent.ConcurrentHashMap 8 | 9 | 10 | class InMemoryEventStore(init: Map<String, List<EventEnvelope>> = emptyMap()): EventStore { 11 | 12 | private val data: ConcurrentHashMap<String, List<EventEnvelope>> = ConcurrentHashMap(init) 13 | 14 | private val events = PublishSubject.create<EventEnvelope>() 15 | 16 | private fun values(): List<EventEnvelope> = data.values.flatMap { it } 17 | 18 | override fun loadEvents(): Observable<EventEnvelope> = 19 | Observable.fromIterable(values()) 20 | 21 | override fun loadEvents(sequenceNum: Long): Observable<EventEnvelope> = 22 | loadEvents().filter { it.sequence >= sequenceNum } 23 | 24 | override fun loadEventsById(id: String, sequenceNum: Long): Observable<EventEnvelope> { 25 | TODO("not implemented") //To change body of created functions use File | Settings | File Templates. 26 | } 27 | 28 | override fun eventStream(): Observable<EventEnvelope> = events 29 | 30 | override fun eventStream(id: String): Observable<EventEnvelope> = eventStream().filter { it.entityId == id } 31 | 32 | override fun persist(id: String, event: EventEnvelope): Single<EventEnvelope> { 33 | val l: List<EventEnvelope> = Option(data[id]).toList().flatMap { l -> l.orEmpty() } 34 | data.put(id, l.plus(event)) 35 | events.onNext(event) 36 | return Single.just(event) 37 | } 38 | 39 | override fun eventStreamByGroupId(groupId: String): Observable<EventEnvelope> { 40 | return eventStream() 41 | } 42 | 43 | override fun commit(groupId: String, sequenceNum: Long): Single<Unit> { 44 | return Single.just(Unit) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/eventsourcing/PostgresEventStore.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons.eventsourcing 2 | 3 | import io.reactivex.Observable 4 | import io.reactivex.Single 5 | import io.reactivex.subjects.PublishSubject 6 | import io.vertx.core.json.JsonObject 7 | import io.vertx.kotlin.core.json.array 8 | import io.vertx.kotlin.core.json.json 9 | import io.vertx.reactivex.ext.sql.SQLClient 10 | import org.slf4j.Logger 11 | import org.slf4j.LoggerFactory 12 | import java.time.LocalDateTime 13 | import java.time.format.DateTimeFormatter 14 | 15 | class PostgresEventStore(private val table: String, private val offetsTable: String, private val pgClient: SQLClient) : 16 | EventStore { 17 | 18 | private val events = PublishSubject.create<EventEnvelope>() 19 | 20 | companion object { 21 | val LOGGER: Logger = LoggerFactory.getLogger(PostgresEventStore::class.java) 22 | } 23 | 24 | override fun loadEvents(): Observable<EventEnvelope> { 25 | return pgClient 26 | .rxQuery("SELECT * FROM $table ORDER BY sequence_num") 27 | .toObservable() 28 | .flatMap { 29 | Observable.fromIterable(it.rows.map { rowToEvent(it) }) 30 | } 31 | } 32 | 33 | override fun loadEvents(sequenceNum: Long): Observable<EventEnvelope> { 34 | return pgClient 35 | .rxQueryWithParams( 36 | "SELECT * FROM $table WHERE sequence_num > ? ORDER BY sequence_num", 37 | json { array(sequenceNum) } 38 | ) 39 | .toObservable() 40 | .flatMap { 41 | Observable.fromIterable(it.rows.map { rowToEvent(it) }) 42 | } 43 | } 44 | 45 | override fun loadEventsById(id: String, sequenceNum: Long): Observable<EventEnvelope> { 46 | return pgClient 47 | .rxQueryWithParams( 48 | "SELECT * FROM $table WHERE sequence_num > ? and entity_id = ? ORDER BY sequence_num", 49 | json { array(sequenceNum, id) } 50 | ) 51 | .toObservable() 52 | .flatMap { 53 | Observable.fromIterable(it.rows.map { rowToEvent(it) }) 54 | } 55 | } 56 | 57 | private fun rowToEvent(event: JsonObject): EventEnvelope = 58 | EventEnvelope( 59 | event.getString("unique_id"), 60 | event.getString("entity_id"), 61 | event.getLong("sequence_num"), 62 | event.getString("event_type"), 63 | event.getString("version"), 64 | JsonObject(event.getString("event")), 65 | LocalDateTime.parse(event.getString("created_at"), DateTimeFormatter.ISO_DATE_TIME), 66 | JsonObject(event.getString("metadata")) 67 | ) 68 | 69 | override fun eventStream(): Observable<EventEnvelope> = events 70 | 71 | override fun eventStreamByGroupId(groupId: String): Observable<EventEnvelope> { 72 | return pgClient.rxQueryWithParams( 73 | "SELECT sequence_num FROM $offetsTable WHERE group_id = ? ", 74 | json { array(groupId) }) 75 | .toObservable() 76 | .flatMap { r -> 77 | if (r.rows.isEmpty()) { 78 | eventStream() 79 | } else { 80 | val first: JsonObject = r.rows.first() 81 | val sequenceNum = first.getLong("sequence_num") 82 | loadEvents(sequenceNum).concatWith(eventStream()) 83 | } 84 | } 85 | } 86 | 87 | override fun commit(groupId: String, sequenceNum: Long): Single<Unit> { 88 | LOGGER.debug("Upsert last commit for $groupId to $sequenceNum") 89 | val query = """INSERT INTO $offetsTable (group_id, sequence_num) VALUES(?, ?) ON CONFLICT (group_id) DO UPDATE SET sequence_num = ?""" 90 | return pgClient.rxUpdateWithParams(query, json { array(groupId, sequenceNum, sequenceNum) }).map { _ -> Unit } 91 | } 92 | 93 | override fun eventStream(id: String): Observable<EventEnvelope> = eventStream().filter { it.entityId == id } 94 | 95 | override fun persist(id: String, event: EventEnvelope): Single<EventEnvelope> { 96 | return pgClient 97 | .rxUpdateWithParams( 98 | """ 99 | INSERT INTO $table ( 100 | unique_id, 101 | entity_id, 102 | sequence_num, 103 | event_type, 104 | version, 105 | event, 106 | created_at, 107 | metadata 108 | ) VALUES (?, ?, ?, ?, ?, ?::JSON, ?, ?::JSON) 109 | """, 110 | json { 111 | array( 112 | event.uniqueId, 113 | event.entityId, 114 | event.sequence, 115 | event.eventType, 116 | event.version, 117 | event.event.encode(), 118 | DateTimeFormatter.ISO_DATE_TIME.format(event.date), 119 | event.metadata.encode() 120 | ) 121 | } 122 | ) 123 | .map { _ -> 124 | events.onNext(event) 125 | event 126 | } 127 | } 128 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/eventsourcing/eventsourcing.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons.eventsourcing 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import fr.maif.automate.commons.Error 7 | import io.reactivex.Observable 8 | import io.reactivex.Single 9 | import io.vertx.core.json.JsonObject 10 | import org.slf4j.Logger 11 | import org.slf4j.LoggerFactory 12 | import java.time.LocalDateTime 13 | import java.util.concurrent.atomic.AtomicLong 14 | 15 | 16 | interface Command 17 | interface Event { 18 | fun type(): String = this::class.java.simpleName 19 | fun toJson(): JsonObject 20 | fun exposedJson(): JsonObject = toJson() 21 | fun version(): String = "1.0.0" 22 | } 23 | 24 | data class EventEnvelope( 25 | val uniqueId: String, 26 | val entityId: String, 27 | val sequence: Long, 28 | val eventType: String, 29 | val version: String, 30 | val event: JsonObject, 31 | val date: LocalDateTime = LocalDateTime.now(), 32 | val metadata: JsonObject = JsonObject() 33 | ) 34 | 35 | interface EventStore { 36 | 37 | fun loadEvents(): Observable<EventEnvelope> 38 | 39 | fun loadEvents(sequenceNum: Long): Observable<EventEnvelope> 40 | fun loadEventsById(id: String, sequenceNum: Long = 0): Observable<EventEnvelope> 41 | 42 | fun eventStream(): Observable<EventEnvelope> 43 | 44 | fun eventStreamByGroupId(groupId: String): Observable<EventEnvelope> 45 | 46 | fun commit(groupId: String, sequenceNum: Long): Single<Unit> 47 | 48 | fun eventStream(id: String): Observable<EventEnvelope> 49 | 50 | fun persist(id: String, event: EventEnvelope): Single<EventEnvelope> 51 | } 52 | 53 | interface EventReader<E : Event> { 54 | fun read(envelope: EventEnvelope): E 55 | } 56 | 57 | abstract class Store<S, C : Command, E : Event>( 58 | private val name: String, 59 | private val initialState: () -> S & Any, 60 | private val eventStore: EventStore 61 | ) { 62 | 63 | companion object { 64 | val LOGGER: Logger = LoggerFactory.getLogger(Store::class.java) 65 | } 66 | 67 | private val lastSequence = AtomicLong(0) 68 | 69 | fun state(): Single<S> { 70 | LOGGER.debug("Loading state for $name") 71 | val loadEvents = eventStore.loadEvents() 72 | return loadEvents 73 | .reduce(initialState()) { acc, e -> nextState(acc, e) } 74 | } 75 | 76 | private fun nextState(acc: S, e: EventEnvelope): S & Any { 77 | val nextState = applyEventToState(acc, e) 78 | lastSequence.set(e.sequence) 79 | return nextState 80 | } 81 | 82 | protected abstract fun applyEventToState(current: S, event: EventEnvelope): S & Any 83 | 84 | protected abstract fun applyCommand(state: S, command: C): Single<Either<Error, E>> 85 | 86 | fun onCommand(command: C): Single<Either<Error, E>> = state().flatMap { s -> applyCommand(s, command) } 87 | 88 | protected fun persist(id: String, event: E): Single<Either<Error, EventEnvelope>> { 89 | val nextSequence = nextSequence() 90 | val evt = EventEnvelope("$id-$nextSequence", id, nextSequence, event.type(), event.version(), event.toJson()) 91 | return eventStore 92 | .persist(id, evt).map { it.right() as Either<Error, EventEnvelope> } 93 | .onErrorReturn { e -> 94 | LOGGER.error("Error storing event", e) 95 | Error(" Error storing event ").left() 96 | } 97 | } 98 | 99 | protected fun nextSequence() = lastSequence.incrementAndGet() 100 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/commons/otoroshi.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons 2 | 3 | import arrow.core.* 4 | import arrow.instances.option.applicative.* 5 | import com.auth0.jwt.JWT 6 | import com.auth0.jwt.JWTVerifier 7 | import com.auth0.jwt.algorithms.Algorithm 8 | import com.auth0.jwt.interfaces.DecodedJWT 9 | import fr.maif.automate.administrator.Administrator 10 | import io.vertx.core.Handler 11 | import io.vertx.kotlin.core.json.json 12 | import io.vertx.kotlin.core.json.obj 13 | import io.vertx.reactivex.ext.web.RoutingContext 14 | import org.slf4j.Logger 15 | import org.slf4j.LoggerFactory 16 | 17 | class OtoroshiHandler(private val config: OtoroshiConfig, private val env: Env): Handler<RoutingContext> { 18 | 19 | companion object { 20 | val LOGGER = LoggerFactory.getLogger(OtoroshiHandler::class.java) as Logger 21 | } 22 | 23 | private val algorithm: Algorithm = Algorithm.HMAC512(config.sharedKey) 24 | 25 | private val verifier: JWTVerifier = JWT 26 | .require(algorithm) 27 | .withIssuer(config.issuer) 28 | .acceptLeeway(5000) 29 | .build() 30 | 31 | override fun handle(routingContext: RoutingContext) = 32 | when(env) { 33 | is Dev -> { 34 | routingContext.put("user", Administrator("ragnar", "ragnar.lothbrok@gmail.com", true)) 35 | routingContext.next() 36 | } 37 | is Prod -> { 38 | val req = routingContext.request() 39 | val maybeReqId = req.getHeader(config.headerRequestId).toOption() 40 | val maybeState = req.getHeader(config.headerGatewayState).toOption() 41 | val maybeClaim = req.getHeader(config.headerClaim).toOption() 42 | LOGGER.debug("New request ${req.method()} ${req.absoluteURI()} id = $maybeReqId, state = $maybeState, claim = $maybeClaim") 43 | maybeReqId.forall { id -> 44 | 45 | val method = routingContext.request().method() 46 | val uri = routingContext.request().uri() 47 | val headers = routingContext.request().headers() 48 | val strHeaders = headers.names().map { n -> 49 | "$n: [${headers.getAll(n).joinToString("," )}]" 50 | } 51 | LOGGER.debug( 52 | "Request from Gateway with id : $id => $method $uri with request headers $strHeaders" 53 | ) 54 | true 55 | } 56 | 57 | Option.applicative().tupled(maybeState, maybeClaim).fix().map { (state, claim) -> 58 | 59 | Try { 60 | val decoded: DecodedJWT = verifier.verify(claim) 61 | val otoAdmin: Option<Administrator> = Administrator.fromOtoroshiJwtToken(decoded) 62 | when(otoAdmin) { 63 | is Some -> { 64 | routingContext.response().headers().add(config.headerGatewayStateResp, state) 65 | routingContext.put("user", otoAdmin.t) 66 | routingContext.next() 67 | } 68 | is None -> { 69 | LOGGER.error("Error no session found ") 70 | routingContext.response().headers().add(config.headerGatewayStateResp, state) 71 | routingContext.response() 72 | .setStatusCode(401) 73 | .endWithJson(json { obj("message" to "Unauthorized" ) }) 74 | } 75 | } 76 | 77 | 78 | }.recover { e -> 79 | LOGGER.error("Error decoding token ", e) 80 | routingContext.response().headers().add(config.headerGatewayStateResp, maybeState.getOrElse { "--" }) 81 | routingContext.response() 82 | .setStatusCode(401) 83 | .endWithJson(json { obj("message" to "Unauthorized" ) }) 84 | } 85 | 86 | Unit 87 | 88 | }.fix().getOrElse { 89 | if(!req.headers().contains(config.providerMonitoringHeader)) LOGGER.error("Error during otoroshi filter") 90 | routingContext.response().headers().add(config.headerGatewayStateResp, maybeState.getOrElse { "--" }) 91 | routingContext.response() 92 | .setStatusCode(401) 93 | .endWithJson(json { obj("message" to "Unauthorized" ) }) 94 | Unit 95 | } 96 | } 97 | } 98 | } -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/dns/DnsManager.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.dns 2 | 3 | import arrow.core.Either 4 | import fr.maif.automate.commons.Error 5 | import io.reactivex.Observable 6 | import io.reactivex.Single 7 | import io.vertx.core.json.JsonObject 8 | import io.vertx.kotlin.core.json.get 9 | 10 | object Unit 11 | 12 | data class Domain(val name: String, val records: List<Record>) 13 | data class DomainResume(val name: String) 14 | data class Record(val id: Long? = null, val target: String, val ttl: Long, val fieldType: String, val subDomain: String) { 15 | companion object { 16 | fun fromJson(json: JsonObject): Record = 17 | Record( 18 | id = json.getLong("entityId"), 19 | target = json["target"], 20 | ttl = json.getLong("ttl"), 21 | fieldType = json["fieldType"], 22 | subDomain = json["subDomain"] 23 | ) 24 | } 25 | } 26 | 27 | 28 | interface DnsManager { 29 | fun listDomains(): Observable<DomainResume> 30 | fun getDomain(name: String): Single<Domain> 31 | fun checkRecord(domain: String, record: Record): Single<List<String>> 32 | fun createRecord(domain: String, record: Record): Single<Either<Error, Record>> 33 | fun updateRecord(domain: String, recordId: Long, record: Record): Single<Either<Error, Record>> 34 | fun deleteRecord(domain: String, recordId: Long): Single<Either<Error, Unit>> 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/dns/DnsRouter.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.dns 2 | 3 | import io.vertx.core.Handler 4 | import io.vertx.reactivex.ext.web.RoutingContext 5 | import fr.maif.automate.commons.* 6 | import io.vertx.kotlin.core.json.* 7 | import arrow.core.* 8 | 9 | class DnsRouter(dnsManager: DnsManager) { 10 | 11 | val listDomains = Handler<RoutingContext> { req -> 12 | dnsManager.listDomains() 13 | .toList() 14 | .subscribe ({ json -> 15 | req.response().endWithJson(json) 16 | }, { err -> 17 | req.response().setStatusCode(500).endWithJson(json { 18 | obj("message" to err.message) 19 | }) 20 | }) 21 | } 22 | 23 | val createRecord = Handler<RoutingContext> { req -> 24 | val domain = req.pathParam("domain") 25 | val record = Record.fromJson(req.body().asJsonObject()) 26 | dnsManager.createRecord(domain, record) 27 | .subscribe ({ result -> 28 | when(result) { 29 | is Either.Right -> 30 | req.response().endWithJson(json { obj ()}) 31 | is Either.Left -> 32 | req.response().setStatusCode(400).endWithJson(result.a) 33 | } 34 | }, { err -> 35 | req.response().setStatusCode(500).endWithJson(json { 36 | obj("message" to err.message) 37 | }) 38 | }) 39 | } 40 | 41 | val updateRecord = Handler<RoutingContext> { req -> 42 | val domain = req.pathParam("domain") 43 | val recordId = req.pathParam("recordId") 44 | val record = Record.fromJson(req.body().asJsonObject()) 45 | dnsManager.updateRecord(domain, recordId.toLong(), record) 46 | .subscribe ({ result -> 47 | when(result) { 48 | is Either.Right -> 49 | req.response().endWithJson(json { obj ()}) 50 | is Either.Left -> 51 | req.response().setStatusCode(400).endWithJson(result.a) 52 | } 53 | }, { err -> 54 | req.response().setStatusCode(500).endWithJson(json { 55 | obj("message" to err.message) 56 | }) 57 | }) 58 | } 59 | 60 | val deleteRecord = Handler<RoutingContext> { req -> 61 | val domain = req.pathParam("domain") 62 | val recordId = req.pathParam("recordId") 63 | dnsManager.deleteRecord(domain, recordId.toLong()) 64 | .subscribe ({ result -> 65 | when(result) { 66 | is Either.Right -> 67 | req.response().endWithJson(json { obj() }) 68 | is Either.Left -> 69 | req.response().setStatusCode(400).endWithJson(result.a) 70 | } 71 | }, { err -> 72 | req.response().setStatusCode(500).endWithJson(json { 73 | obj("message" to err.message) 74 | }) 75 | }) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/letsencrypt/LetSEncryptManager.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.letsencrypt 2 | 3 | import arrow.core.Either 4 | import arrow.core.Option 5 | import fr.maif.automate.certificate.write.CertificateOrdered 6 | import fr.maif.automate.commons.Error 7 | import fr.maif.automate.commons.stringify 8 | import fr.maif.automate.commons.x509FromString 9 | import io.reactivex.Single 10 | import io.vertx.core.json.JsonObject 11 | import io.vertx.kotlin.core.json.get 12 | import io.vertx.kotlin.core.json.json 13 | import io.vertx.kotlin.core.json.obj 14 | import org.shredzone.acme4j.util.KeyPairUtils 15 | import java.io.StringReader 16 | import java.security.KeyPair 17 | import java.security.cert.X509Certificate 18 | import java.time.LocalDateTime 19 | import java.time.format.DateTimeFormatter 20 | 21 | data class Certificate(val certificate : X509Certificate, val expire: LocalDateTime, val chain: List<X509Certificate>) { 22 | 23 | companion object { 24 | fun fromJson(json: JsonObject): Certificate = 25 | Certificate( 26 | certificate = x509FromString(json["certificate"]), 27 | expire = LocalDateTime.parse(json.getString("expire"), DateTimeFormatter.ISO_DATE_TIME), 28 | chain = json.getJsonArray("chain").list.map { x509FromString(it.toString()) } 29 | ) 30 | } 31 | 32 | fun toJson(): JsonObject = 33 | json {obj( 34 | "expire" to DateTimeFormatter.ISO_DATE_TIME.format(expire), 35 | "certificate" to certificate.stringify(), 36 | "chain" to chain.map { it.stringify() } 37 | )} 38 | 39 | override fun equals(other: Any?): Boolean { 40 | if (other is Certificate) { 41 | return ( 42 | other.certificate.stringify() == certificate.stringify() && 43 | other.expire == expire 44 | ) 45 | } 46 | return false 47 | } 48 | 49 | override fun hashCode(): Int { 50 | return super.hashCode() 51 | } 52 | } 53 | data class LetSEncryptCertificate(val domain: String, val privateKey: KeyPair, val csr: String, val certificate: Certificate) 54 | 55 | data class LetSEncryptAccount(val userId: String, val keys: KeyPair) { 56 | 57 | companion object { 58 | fun fromJson(json: JsonObject): LetSEncryptAccount = 59 | LetSEncryptAccount( 60 | userId = json.getString("userId"), 61 | keys = KeyPairUtils.readKeyPair(StringReader(json.getString("privateKey"))) 62 | ) 63 | 64 | } 65 | 66 | fun toJson(): JsonObject = json { 67 | obj( 68 | "userId" to userId, 69 | "privateKey" to keys.stringify() 70 | ) 71 | } 72 | } 73 | 74 | interface LetSEncryptManager { 75 | 76 | fun orderCertificate(domain: String, subdomain: Option<String>, isWildCard: Boolean): Single<Either<Error, LetSEncryptCertificate>> 77 | 78 | } 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/main/java/fr/maif/automate/publisher/CertificatePublisher.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.publisher 2 | 3 | import arrow.core.* 4 | import fr.maif.automate.commons.CleverConfig 5 | import fr.maif.automate.commons.Error 6 | import fr.maif.automate.commons.stringify 7 | import fr.maif.automate.letsencrypt.Certificate 8 | import io.reactivex.Single 9 | import io.vertx.reactivex.core.MultiMap 10 | import io.vertx.reactivex.ext.web.client.WebClient 11 | import org.slf4j.Logger 12 | import org.slf4j.LoggerFactory 13 | import java.security.KeyPair 14 | import java.util.* 15 | 16 | interface CertificatePublisher { 17 | 18 | fun publishCertificate(domain: String, privateKey: KeyPair, csr: String, certificate: Certificate): Single<Either<Error, Unit>> 19 | 20 | } 21 | 22 | class CleverCloudCertificateConsumer(private val cleverConfig: CleverConfig, private val client: WebClient): CertificatePublisher { 23 | 24 | companion object { 25 | val random = Random().nextInt(1000000000) 26 | val LOGGER = LoggerFactory.getLogger(CleverCloudCertificateConsumer::class.java) as Logger 27 | } 28 | 29 | override fun publishCertificate(domain: String, privateKey: KeyPair, csr: String, certificate: Certificate): Single<Either<Error, Unit>> { 30 | val uri = "${cleverConfig.host}/v2/certificates" 31 | LOGGER.info("POST $uri") 32 | return client 33 | .postAbs(uri) 34 | .putHeader("Authorization", authorizationHeader()) 35 | .rxSendForm(MultiMap.caseInsensitiveMultiMap() 36 | .set("pem", "${privateKey.stringify()}\n${ certificate.chain.joinToString("\n") { it.stringify()} }") 37 | ) 38 | .map { resp -> 39 | LOGGER.info("POST $uri respond ${resp.statusCode()} ${resp.bodyAsString()}") 40 | when(resp.statusCode()) { 41 | 200 -> 42 | Right(Unit) 43 | 201 -> 44 | Right(Unit) 45 | else -> 46 | Left(Error("Error while sending certificate to clever cloud: ${resp.statusCode()} ${resp.bodyAsString()}")) 47 | } 48 | } 49 | } 50 | 51 | private fun authorizationHeader(): String { 52 | val attr = listOf( 53 | Pair("OAuth realm", "${cleverConfig.host}/oauth"), 54 | Pair("oauth_consumer_key", cleverConfig.consumerKey), 55 | Pair("oauth_token", cleverConfig.clientToken), 56 | Pair("oauth_signature_method", "PLAINTEXT"), 57 | Pair("oauth_signature", "${cleverConfig.consumerSecret}&${cleverConfig.clientSecret}"), 58 | Pair("oauth_timestamp", "${Math.floor( (System.currentTimeMillis() / 1000).toDouble() )}"), 59 | Pair("oauth_nonce", "$random") 60 | ) 61 | return attr.joinToString(",") { (k, v) -> """$k="$v"""" } 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | env = dev 2 | env = ${?ENV} 3 | 4 | http { 5 | port = 8080 6 | port = ${?HTTP_PORT} 7 | host = "0.0.0.0" 8 | host = ${?HTTP_HOST} 9 | } 10 | 11 | logout = "" 12 | logout = ${?LOGOUT_URL} 13 | 14 | certificates { 15 | pollingInterval = { 16 | period = 5 17 | period = ${?LETSENCRYPT_POLLING_PERIOD} 18 | unit = HOURS 19 | unit = ${?LETSENCRYPT_POLLING_UNIT} 20 | } 21 | } 22 | 23 | ovh { 24 | applicationKey = xxxx 25 | applicationKey = ${?OVH_APPLICATION_KEY} 26 | applicationSecret = xxxx 27 | applicationSecret = ${?OVH_APPLICATION_SECRET} 28 | consumerKey = xxxx 29 | consumerKey = ${?OVH_CONSUMER_KEY} 30 | host = "https://api.ovh.com" 31 | host = ${?OVH_HOST} 32 | 33 | redirectHost = "NA" 34 | host = "https://api.ovh.com" 35 | } 36 | 37 | letsencrypt { 38 | server = "acme://letsencrypt.org/staging" 39 | server = ${?LETSENCRYPT_SERVER} 40 | accountId = "account" 41 | accountId = ${?LETSENCRYPT_ACCOUNT_ID} 42 | } 43 | 44 | postgres { 45 | host = "localhost" 46 | host = ${?POSTGRESQL_ADDON_HOST} 47 | port = 5432 48 | port = ${?POSTGRESQL_ADDON_PORT} 49 | database = "lets_automate" 50 | database = ${?POSTGRESQL_ADDON_DB} 51 | username = "default_user" 52 | username = ${?POSTGRESQL_ADDON_USER} 53 | password = "password" 54 | password = ${?POSTGRESQL_ADDON_PASSWORD} 55 | maxPoolSize = 3 56 | maxPoolSize = ${?POSTGRESQL_ADDON_MAX_POOL_SIZE} 57 | } 58 | 59 | clevercloud { 60 | host = "https://api.clever-cloud.com/" 61 | host = ${?CLEVER_HOST} 62 | consumerKey = xxxx 63 | consumerKey = ${?CLEVER_CONSUMER_KEY} 64 | consumerSecret = xxxx 65 | consumerSecret = ${?CLEVER_CONSUMER_SECRET} 66 | clientToken = xxxx 67 | clientToken = ${?CLEVER_CLIENT_TOKEN} 68 | clientSecret = xxxx 69 | clientSecret = ${?CLEVER_CLIENT_SECRET} 70 | } 71 | 72 | otoroshi { 73 | headerRequestId = "Otoroshi-Request-Id" 74 | headerRequestId = ${?FILTER_REQUEST_ID_HEADER_NAME} 75 | headerGatewayStateResp = "Otoroshi-State-Resp" 76 | headerGatewayStateResp = ${?FILTER_GATEWAY_STATE_RESP_HEADER_NAME} 77 | headerGatewayState = "Otoroshi-State" 78 | headerGatewayState = ${?FILTER_GATEWAY_STATE_HEADER_NAME} 79 | headerClaim = "Otoroshi-Claim" 80 | headerClaim = ${?FILTER_CLAIM_HEADER_NAME} 81 | sharedKey = "NA" 82 | sharedKey = ${?CLAIM_SHAREDKEY} 83 | issuer = "Otoroshi" 84 | issuer = ${?OTOROSHI_ISSUER} 85 | providerMonitoringHeader = "X-CleverCloud-Monitoring" 86 | providerMonitoringHeader = ${?OTOROSHI_PROVIDER_MONITORING_HEADER} 87 | } 88 | 89 | teams { 90 | url = xxxx 91 | url = ${?TEAMS_URL} 92 | } -------------------------------------------------------------------------------- /src/main/resources/db_changes.sql: -------------------------------------------------------------------------------- 1 | --liquibase formatted sql 2 | 3 | --changeset letsautomate:1 4 | CREATE TABLE IF NOT EXISTS account ( 5 | accountId varchar(50) PRIMARY KEY, 6 | payload json NOT NULL 7 | ); 8 | --rollback drop table account; 9 | 10 | 11 | --changeset letsautomate:2 12 | CREATE TABLE IF NOT EXISTS certificate_events ( 13 | unique_id varchar(100) PRIMARY KEY, 14 | entity_id varchar(100) NOT NULL, 15 | sequence_num bigint NOT NULL, 16 | event_type varchar(100) NOT NULL, 17 | version varchar(50) NOT NULL, 18 | event json NOT NULL, 19 | created_at timestamp NOT NULL, 20 | metadata json NOT NULL, 21 | UNIQUE (entity_id, sequence_num) 22 | ); 23 | --rollback drop table certificate_events; 24 | 25 | 26 | --changeset letsautomate:3 27 | CREATE TABLE IF NOT EXISTS certificate_events_offsets ( 28 | group_id varchar(100) PRIMARY KEY, 29 | sequence_num bigint NOT NULL 30 | ) 31 | --rollback drop table certificate_events_offsets; -------------------------------------------------------------------------------- /src/main/resources/dev.conf: -------------------------------------------------------------------------------- 1 | include "application.conf" 2 | 3 | ovh { 4 | applicationKey = "xxxx" 5 | applicationSecret = "xxxx" 6 | consumerKey = "xxxx" 7 | } 8 | 9 | clevercloud { 10 | host = "https://api.clever-cloud.com/" 11 | consumerKey = "xxxx" 12 | consumerSecret = "xxxx" 13 | clientToken = "xxxx" 14 | clientSecret = "xxxx" 15 | } 16 | 17 | teams { 18 | url = "https://xxxx" 19 | } -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | <!-- https://www.playframework.com/documentation/latest/SettingsLogger --> 2 | <configuration> 3 | 4 | <appender name="FILE" class="ch.qos.logback.core.FileAppender"> 5 | <file>${application.home:-.}/logs/application.log</file> 6 | <encoder> 7 | <pattern>%date [%level] from %logger in %thread - %message%n%xException</pattern> 8 | </encoder> 9 | </appender> 10 | 11 | <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> 12 | <encoder> 13 | <pattern>%logger{15} - %message%n%xException{10}</pattern> 14 | </encoder> 15 | </appender> 16 | 17 | <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender"> 18 | <appender-ref ref="FILE" /> 19 | </appender> 20 | 21 | <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender"> 22 | <appender-ref ref="STDOUT" /> 23 | </appender> 24 | 25 | 26 | 27 | <root level="INFO"> 28 | <appender-ref ref="ASYNCSTDOUT" /> 29 | </root> 30 | 31 | </configuration> -------------------------------------------------------------------------------- /src/main/resources/public/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/src/main/resources/public/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /src/main/resources/public/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/src/main/resources/public/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /src/main/resources/public/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/src/main/resources/public/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /src/main/resources/public/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/src/main/resources/public/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /src/main/resources/public/img/letsAutomate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MAIF/lets-automate/4b1a37804521ed0428360be8889f91a4e43c525e/src/main/resources/public/img/letsAutomate.png -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/certificate/scheduler/CertificateRenewerTest.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.scheduler 2 | 3 | import arrow.core.None 4 | import fr.maif.automate.certificate.write.CertificateEventStoreTest 5 | import fr.maif.automate.certificate.write.State 6 | import fr.maif.automate.commons.x509FromString 7 | import fr.maif.automate.letsencrypt.Certificate 8 | import io.kotlintest.forAll 9 | import io.kotlintest.* 10 | import io.kotlintest.specs.StringSpec 11 | import org.shredzone.acme4j.util.KeyPairUtils 12 | import java.time.LocalDateTime 13 | import java.time.Month 14 | 15 | class CertificateRenewerTest: StringSpec() { 16 | 17 | init { 18 | 19 | "Certificate should be expired" { 20 | 21 | val domain = "viking.com" 22 | val privateKey = KeyPairUtils.createKeyPair(2048) 23 | val csr = "csr" 24 | val x509Certificate = x509FromString(CertificateEventStoreTest.cert) 25 | val certificate = Certificate(x509Certificate, LocalDateTime.of(2019, Month.FEBRUARY, 11, 0, 0, 0), emptyList()) 26 | val wildcard = true 27 | 28 | val now = LocalDateTime.of(2019, Month.JANUARY, 13, 0, 0, 0) 29 | 30 | val cert = State.CertificateState(domain, None, wildcard, false, privateKey, csr, certificate) 31 | CertificateRenewer.isCertificateExpired(cert, now) shouldBe true 32 | } 33 | 34 | "Certificate should not be expired" { 35 | 36 | val domain = "viking.com" 37 | val privateKey = KeyPairUtils.createKeyPair(2048) 38 | val csr = "csr" 39 | val x509Certificate = x509FromString(CertificateEventStoreTest.cert) 40 | 41 | val certificate = Certificate(x509Certificate, LocalDateTime.of(2019, Month.FEBRUARY, 11, 0, 0, 0), emptyList()) 42 | val wildcard = true 43 | 44 | val now = LocalDateTime.of(2018, Month.NOVEMBER, 13, 0, 0, 0) 45 | 46 | val cert = State.CertificateState(domain, None, wildcard, false, privateKey, csr, certificate) 47 | CertificateRenewer.isCertificateExpired(cert, now) shouldBe false 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/certificate/write/CertificateEventStoreTest.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.certificate.write 2 | 3 | import arrow.core.* 4 | import arrow.core.Either.Left 5 | import arrow.core.Either.Right 6 | import com.fasterxml.jackson.databind.ObjectMapper 7 | import com.fasterxml.jackson.module.kotlin.KotlinModule 8 | import com.fasterxml.jackson.module.kotlin.registerKotlinModule 9 | import com.nhaarman.mockitokotlin2.doReturn 10 | import com.nhaarman.mockitokotlin2.mock 11 | import fr.maif.automate.commons.Error 12 | import fr.maif.automate.commons.eventsourcing.InMemoryEventStore 13 | import fr.maif.automate.commons.stringify 14 | import fr.maif.automate.commons.x509FromString 15 | import fr.maif.automate.letsencrypt.Certificate 16 | import fr.maif.automate.letsencrypt.LetSEncryptManager 17 | import fr.maif.automate.letsencrypt.LetSEncryptCertificate 18 | import fr.maif.automate.publisher.CertificatePublisher 19 | import io.kotlintest.forAll 20 | import io.kotlintest.* 21 | import io.kotlintest.matchers.* 22 | import io.kotlintest.specs.StringSpec 23 | import io.reactivex.Single 24 | import io.vertx.core.json.Json 25 | import org.shredzone.acme4j.util.KeyPairUtils 26 | import java.security.KeyPair 27 | import java.time.LocalDateTime 28 | import fr.maif.automate.* 29 | 30 | class CertificateEventStoreTest: StringSpec() { 31 | 32 | companion object { 33 | val cert = "-----BEGIN CERTIFICATE-----\nMIIF5DCCBMygAwIBAgITAPqtrC8s+8n0MgD7U+jJnGjGEjANBgkqhkiG9w0BAQsF\nADAiMSAwHgYDVQQDDBdGYWtlIExFIEludGVybWVkaWF0ZSBYMTAeFw0xODA1MjYx\nMzQ5MzhaFw0xODA4MjQxMzQ5MzhaMBcxFTATBgNVBAMTDGFkZWxlZ3VlLmNvbTCC\nASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALR3/VIDSu7RUOW0mA8utFPY\nyPwpdzANULd33cW5t1APszVn+JPPR5MXfAXeKU1wPP27l2wYoLpAuNxSDhjDasB9\n6gSIHW53Q1O5GeXtOropriRpney3gSYHumi0SXoEoKa4TE26enR1YmewRX/IY9bu\nI3MgXtkxl1a2E/R4jg2wBm4e0O9aUsot4FiMSVVywQyzoeZuMDx06bx6NffrDCBo\nmLPm6y636GjZpViXJ1Sp7QNuzwLuCJz8Cr0D4RdyWVCp41SJQtNMNjzdlSc+LIns\nl5BhUKRotu0wBveKCmYNX/yMMd5r8S2A94C88uk8DJTY9WJMaCjCoqHa9G4oc/cC\nAwEAAaOCAxwwggMYMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD\nAQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUPw0mycrqJuKN3TFa\nPryGqLZA/PkwHwYDVR0jBBgwFoAUwMwDRrlYIMxccnDz4S7LIKb1aDowdwYIKwYB\nBQUHAQEEazBpMDIGCCsGAQUFBzABhiZodHRwOi8vb2NzcC5zdGctaW50LXgxLmxl\ndHNlbmNyeXB0Lm9yZzAzBggrBgEFBQcwAoYnaHR0cDovL2NlcnQuc3RnLWludC14\nMS5sZXRzZW5jcnlwdC5vcmcvMBcGA1UdEQQQMA6CDGFkZWxlZ3VlLmNvbTCB/gYD\nVR0gBIH2MIHzMAgGBmeBDAECATCB5gYLKwYBBAGC3xMBAQEwgdYwJgYIKwYBBQUH\nAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5cHQub3JnMIGrBggrBgEFBQcCAjCBngyB\nm1RoaXMgQ2VydGlmaWNhdGUgbWF5IG9ubHkgYmUgcmVsaWVkIHVwb24gYnkgUmVs\neWluZyBQYXJ0aWVzIGFuZCBvbmx5IGluIGFjY29yZGFuY2Ugd2l0aCB0aGUgQ2Vy\ndGlmaWNhdGUgUG9saWN5IGZvdW5kIGF0IGh0dHBzOi8vbGV0c2VuY3J5cHQub3Jn\nL3JlcG9zaXRvcnkvMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYA3Zk0/KXnJIDJ\nVmh9gTSZCEmySfe1adjHvKs/XMHzbmQAAAFjnO0rDQAABAMARzBFAiEA5byNN4cd\n28+twc1zzFZbQZrAm4aYl7UdjRFZRjwFFYYCIHWsrLP3oESyNJ/CUPXjbIbdICDM\n14ONLguK67WpF9vnAHYAsMyD5aX5fWuvfAnMKEkEhyrH6IsTLGNQt8b9JuFsbHcA\nAAFjnO0rVAAABAMARzBFAiEA9SBThXJy7u5wJsYiXqd6UVgGDHewi2nC+tYXkej3\nVL0CIH/DYwtHMwHfdAlesxGVwAkIXUAy1Qwma/MtB16i4tS8MA0GCSqGSIb3DQEB\nCwUAA4IBAQAmyw/gxzAau2QBKn13eKK/RNK82h6daxnLFI81uHWBn33hvOnLK/ic\n/TAZVov4Ni8b89SyWy1HglZorASLqFfQIVnec1RxuscceQhSYhC5doiLt/AWHWCU\n5y4QUCjWj4usSGtZiF6YFdpi4KDLz1WM/4ownJpV2p4HRCwX6SIhilBqFIpiDI5e\nlGqHWEZWYl30b+3wMg5HThcyKwXbD0ThDPP7isWPBP9vmhNnB6cUSArA1fG6YN6/\nmUTMrnSM50Ts0ZGT8bbOpi+rPHzqjubU7J2qvd7mOI3UI+PEM1XVCgJn9RJ+RS+D\n9yRsEGgi43/trdFxdo9/DWaoqdUU42b6\n-----END CERTIFICATE-----\n" 34 | } 35 | 36 | init { 37 | ObjectMapper().registerKotlinModule() 38 | 39 | " Command should emit expected event" { 40 | 41 | //INIT 42 | val domain = "viking.com" 43 | val privateKey = KeyPairUtils.createKeyPair(2048) 44 | val csr = "csr" 45 | val x509Certificate =x509FromString(cert) 46 | val certificate = Certificate(x509Certificate, LocalDateTime.now(), emptyList()) 47 | val wildcard = true 48 | 49 | val letSEncryptManager = mock<LetSEncryptManager>{ 50 | on { orderCertificate(domain, None, wildcard) } 51 | .doReturn(Single.just(LetSEncryptCertificate(domain, privateKey, csr, certificate).right() as Either<Error, LetSEncryptCertificate>)) 52 | } 53 | 54 | val certificatePublisher = object: CertificatePublisher { 55 | override fun publishCertificate(domain: String, privateKey: KeyPair, csr: String, certificate: Certificate): Single<Either<Error, Unit>> = 56 | Single.just(Unit.right() as Either<Error, Unit>) 57 | } 58 | 59 | val eventStore = InMemoryEventStore() 60 | val certificateEventStore = CertificateEventStore("certificate", letSEncryptManager, certificatePublisher, eventStore) 61 | 62 | //TESTS 63 | val certificateCreated = certificateEventStore.onCommand(CreateCertificate(domain, None, wildcard)).blockingGet() 64 | certificateCreated shouldBe CertificateCreated(domain, None, wildcard) 65 | certificateEventStore shouldHaveState State.CertificateState(domain, None, wildcard) 66 | 67 | val certificateOrdered = certificateEventStore.onCommand(OrderCertificate(domain, None, wildcard)).blockingGet() 68 | certificateOrdered shouldBe CertificateOrdered(domain, None, wildcard, privateKey, csr, certificate) 69 | certificateEventStore shouldHaveState State.CertificateState(domain, None, wildcard, false, privateKey, csr, certificate) 70 | 71 | val certificateReOrderedStarted = certificateEventStore.onCommand(StartRenewCertificate(domain, None, wildcard)).blockingGet() 72 | certificateReOrderedStarted shouldBe CertificateReOrderedStarted(domain, None, wildcard) 73 | certificateEventStore shouldHaveState State.CertificateState(domain, None, wildcard, true, privateKey, csr, certificate) 74 | 75 | val certificateReOrdered = certificateEventStore.onCommand(RenewCertificate(domain, None, wildcard)).blockingGet() 76 | certificateReOrdered shouldBe CertificateReOrdered(domain, None, wildcard, privateKey, csr, certificate) 77 | certificateEventStore shouldHaveState State.CertificateState(domain, None, wildcard, false, privateKey, csr, certificate) 78 | 79 | val certificatePublished = certificateEventStore.onCommand(PublishCertificate(domain, None)).blockingGet() 80 | certificatePublished shouldBe { e -> 81 | val event = e as CertificatePublished 82 | event.domain shouldBe domain 83 | } 84 | certificateEventStore shouldHaveState State.CertificateState(domain, None, wildcard, false, privateKey, csr, certificate) 85 | } 86 | 87 | 88 | "A domain should exist when a certificate is ordered or renewed" { 89 | val domain = "viking.com" 90 | val privateKey = KeyPairUtils.createKeyPair(2048) 91 | val csr = "csr" 92 | val x509Certificate = x509FromString(cert) 93 | val certificate = Certificate(x509Certificate, LocalDateTime.now(), emptyList()) 94 | val wildcard = true 95 | 96 | val letSEncryptManager = mock<LetSEncryptManager>() 97 | val certificatePublisher = mock<CertificatePublisher>() 98 | val eventStore = InMemoryEventStore() 99 | val certificateEventStore = CertificateEventStore("certificate", letSEncryptManager, certificatePublisher, eventStore) 100 | 101 | val certificateOrdered = certificateEventStore.onCommand(OrderCertificate(domain, None, wildcard)).blockingGet() 102 | certificateOrdered shouldBeError Error("Domain $domain should be created") 103 | 104 | val certificateReOrdered = certificateEventStore.onCommand(RenewCertificate(domain, None, wildcard)).blockingGet() 105 | certificateReOrdered shouldBeError Error("Domain $domain should be created") 106 | 107 | val certificatePublished = certificateEventStore.onCommand(PublishCertificate(domain, None)).blockingGet() 108 | certificatePublished shouldBeError Error("Domain $domain should be created") 109 | } 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/commons/eventsourcing/PostgresEventStoreTest.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons.eventsourcing 2 | 3 | import fr.maif.automate.certificate.eventhandler.TeamsEventHandler 4 | import io.kotlintest.Spec 5 | import io.kotlintest.TestCase 6 | import io.kotlintest.shouldBe 7 | import io.kotlintest.specs.StringSpec 8 | import io.vertx.kotlin.core.json.json 9 | import io.vertx.kotlin.core.json.obj 10 | import io.vertx.reactivex.core.Vertx 11 | import io.vertx.reactivex.ext.jdbc.JDBCClient 12 | import liquibase.Liquibase 13 | import liquibase.database.Database 14 | import liquibase.database.DatabaseFactory 15 | import liquibase.database.jvm.JdbcConnection 16 | import liquibase.resource.ClassLoaderResourceAccessor 17 | import org.postgresql.ds.PGSimpleDataSource 18 | import org.slf4j.Logger 19 | import org.slf4j.LoggerFactory 20 | import java.time.format.DateTimeFormatter 21 | import java.time.LocalDateTime 22 | import java.time.temporal.ChronoUnit 23 | 24 | class PostgresEventStoreTest : StringSpec() { 25 | val eventDb = "certificate_events" 26 | val offsetDb = "certificate_events_offsets" 27 | val host = "localhost" 28 | val database = "lets_automate_test" 29 | val user = "user_test" 30 | val password = "password_test" 31 | val port = 54321 32 | val vertx = Vertx.vertx() 33 | val url = "jdbc:postgresql://${host}:${port}/${database}?user=${user}&password=${password}" 34 | val pgClient: JDBCClient = JDBCClient.createShared(vertx, json { 35 | obj( 36 | "host" to host, 37 | "port" to port, 38 | "database" to database, 39 | "username" to user, 40 | "password" to password, 41 | "url" to url 42 | ) 43 | }) 44 | 45 | override fun beforeTest(testCase: TestCase) { 46 | // BeforeTest here 47 | pgClient.rxUpdate( 48 | """ 49 | delete from $eventDb 50 | """ 51 | ).flatMap { 52 | pgClient.rxUpdate( 53 | """ 54 | delete from $offsetDb 55 | """ 56 | ) 57 | }.map { Unit }.onErrorReturn { Unit }.blockingGet() 58 | } 59 | 60 | override fun afterSpec(spec: Spec) { 61 | pgClient.close() 62 | } 63 | 64 | init { 65 | 66 | val datasource = PGSimpleDataSource() 67 | datasource.serverNames = arrayOf(host) 68 | datasource.databaseName = database 69 | datasource.user = user 70 | datasource.portNumbers = intArrayOf(port) 71 | datasource.password = password 72 | datasource.setURL(url) 73 | 74 | 75 | val connection = datasource.connection 76 | val db: Database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection)) 77 | val liquidbase = Liquibase("db_changes.sql", ClassLoaderResourceAccessor(), db) 78 | liquidbase.update("") 79 | connection.close() 80 | 81 | 82 | val store = PostgresEventStore(eventDb, offsetDb, pgClient) 83 | val LOGGER = LoggerFactory.getLogger(PostgresEventStoreTest::class.java) as Logger 84 | "CRUD" { 85 | store.loadEvents().toList().blockingGet() shouldBe emptyList<EventEnvelope>() 86 | val event = EventEnvelope( 87 | "1", "1", 1L, 88 | "EventType", "1", json { obj("name" to "test") }, 89 | LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS) 90 | ) 91 | store.persist("1", event).blockingGet() 92 | store.loadEvents().toList().blockingGet() shouldBe listOf(event) 93 | } 94 | 95 | "Reload events" { 96 | val event1 = EventEnvelope( 97 | "1", "1", 1L, 98 | "EventType", "1", json { obj("name" to "test") }, 99 | LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS) 100 | ) 101 | val event2 = EventEnvelope( 102 | "2", "1", 2L, 103 | "EventType", "1", json { obj("name" to "test2") }, 104 | LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS) 105 | ) 106 | store.persist("1", event1).blockingGet() 107 | store.persist("2", event2).blockingGet() 108 | store.commit("test_group", 1L).blockingGet() 109 | 110 | val events = store.eventStreamByGroupId("test_group").take(1).toList().blockingGet() 111 | events shouldBe listOf(event2) 112 | } 113 | } 114 | 115 | 116 | } -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/commons/eventsourcing/StoreTest.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.commons.eventsourcing 2 | 3 | import arrow.core.* 4 | import com.fasterxml.jackson.annotation.JsonCreator 5 | import com.fasterxml.jackson.annotation.JsonProperty 6 | import com.fasterxml.jackson.databind.ObjectMapper 7 | import com.fasterxml.jackson.module.kotlin.registerKotlinModule 8 | import fr.maif.automate.commons.Error 9 | import io.kotlintest .* 10 | import io.kotlintest.matchers.* 11 | import io.kotlintest.specs.* 12 | import io.reactivex.Single 13 | import io.vertx.core.json.JsonObject 14 | import fr.maif.automate.* 15 | 16 | sealed class VikingCommand: Command 17 | data class CreateViking(val id: String): VikingCommand() 18 | data class UpdateName(val name: String): VikingCommand() 19 | data class UpdateCity(val city: String): VikingCommand() 20 | 21 | sealed class VikingEvent: Event { 22 | override fun toJson(): JsonObject = JsonObject.mapFrom(this) 23 | } 24 | data class VikingCreated(@JsonProperty("id") val id: String?): VikingEvent() 25 | data class NameUpdated(@JsonProperty("name") val name: String?): VikingEvent() 26 | data class CityUpdated(@JsonProperty("city") val city: String?): VikingEvent() 27 | 28 | data class Viking(val id: String? = null, val name: String? = null, val city: String? = null) 29 | 30 | class VikingReader: EventReader<VikingEvent> { 31 | override fun read(envelope: EventEnvelope): VikingEvent { 32 | return when(envelope.eventType) { 33 | VikingCreated::class.java.simpleName -> envelope.event.mapTo(VikingCreated::class.java) 34 | NameUpdated::class.java.simpleName -> envelope.event.mapTo(NameUpdated::class.java) 35 | CityUpdated::class.java.simpleName -> envelope.event.mapTo(CityUpdated::class.java) 36 | else -> throw IllegalArgumentException("Unknown type ${envelope.eventType}") 37 | } 38 | } 39 | } 40 | 41 | class VikingStore(id: String, eventStore: EventStore, val reader: VikingReader) : Store<Viking, VikingCommand, VikingEvent>(id, {Viking()}, eventStore) { 42 | 43 | override fun applyEventToState(current: Viking, event: EventEnvelope): Viking { 44 | val evt = reader.read(event) 45 | return when(evt) { 46 | is VikingCreated -> current.copy(id = evt.id) 47 | is NameUpdated -> current.copy(name = evt.name) 48 | is CityUpdated -> current.copy(city = evt.city) 49 | } 50 | } 51 | 52 | override fun applyCommand(state: Viking, command: VikingCommand): Single<Either<Error, VikingEvent>> { 53 | return when(command) { 54 | is CreateViking -> { 55 | if (command.id == "2") { 56 | Single.just(Error("Bad id").left() as Either<Error, VikingEvent>) 57 | } else { 58 | val event = VikingCreated(command.id) 59 | persist("1", event).map { 60 | it.map { event as VikingEvent } 61 | } 62 | } 63 | } 64 | is UpdateName -> { 65 | val event = NameUpdated(command.name) 66 | persist("2", event).map { 67 | it.map { event } 68 | } 69 | } 70 | is UpdateCity -> { 71 | val event = CityUpdated(command.city) 72 | persist("3", event).map { 73 | it.map { event } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | 81 | class StoreTest: FunSpec() { 82 | 83 | init { 84 | ObjectMapper().registerKotlinModule() 85 | applyCommands() 86 | applyCommandAfterRecover() 87 | applyCommandOnErrorAfterRecover() 88 | } 89 | 90 | private fun applyCommands() = test("A command must store event on an empty journal") { 91 | val store = VikingStore("1", InMemoryEventStore(), VikingReader()) 92 | 93 | val id = "entityId" 94 | val name = "Ragnard Lodbrok" 95 | val city = "Kattegat" 96 | 97 | val vikingCreated: Either<Error, VikingEvent> = store.onCommand(CreateViking(id)).blockingGet() 98 | vikingCreated shouldBe VikingCreated(id) 99 | 100 | val nameUpdated: Either<Error, VikingEvent> = store.onCommand(UpdateName(name)).blockingGet() 101 | nameUpdated shouldBe NameUpdated(name) 102 | 103 | val cityUpdated: Either<Error, VikingEvent> = store.onCommand(UpdateCity(city)).blockingGet() 104 | cityUpdated shouldBe CityUpdated(city) 105 | 106 | store.state().blockingGet() shouldBe Viking(id, name, city) 107 | } 108 | 109 | private fun applyCommandAfterRecover() = test("A store must recover and handle command") { 110 | 111 | val id = "1" 112 | val name = "Ragnard Lodbrok" 113 | val city = "Kattegat" 114 | 115 | val initialData: Map<String, List<EventEnvelope>> = mapOf( 116 | Pair(id, listOf( 117 | EventEnvelope("$id-0", id, 0, VikingCreated::class.java.simpleName, "1.0", VikingCreated(id).toJson()), 118 | EventEnvelope("$id-1", id, 1, NameUpdated::class.java.simpleName, "1.0", NameUpdated(name).toJson()) 119 | )) 120 | ) 121 | val eventStore = InMemoryEventStore(initialData) 122 | val store = VikingStore(id, eventStore, VikingReader()) 123 | 124 | val cityUpdated: Either<Error, VikingEvent> = store.onCommand(UpdateCity("Kattegat")).blockingGet() 125 | cityUpdated shouldBe CityUpdated(city) 126 | 127 | store.state().blockingGet() shouldBe Viking(id, name, city) 128 | } 129 | 130 | private fun applyCommandOnErrorAfterRecover() = test("A store must recover and reject command") { 131 | 132 | val id = "1" 133 | val name = "Ragnard Lodbrok" 134 | 135 | val initialData: Map<String, List<EventEnvelope>> = mapOf( 136 | Pair(id, listOf( 137 | EventEnvelope("$id-0", id, 0, VikingCreated::class.java.simpleName, "1.0", VikingCreated(id).toJson()), 138 | EventEnvelope("$id-1", id, 1, NameUpdated::class.java.simpleName, "1.0", NameUpdated(name).toJson()) 139 | )) 140 | ) 141 | val eventStore = InMemoryEventStore(initialData) 142 | val store = VikingStore(id, eventStore, VikingReader()) 143 | 144 | val cityUpdated: Either<Error, VikingEvent> = store.onCommand(CreateViking("2")).blockingGet() 145 | cityUpdated should beInstanceOf(Either.Left::class) 146 | 147 | store.state().blockingGet() shouldBe Viking(id, name) 148 | } 149 | 150 | } 151 | 152 | 153 | -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/dns/DnsManagerTest.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate.dns 2 | 3 | import fr.maif.automate.commons.Error 4 | import fr.maif.automate.commons.LetSEncryptConfig 5 | import fr.maif.automate.commons.LetsAutomateConfig 6 | import fr.maif.automate.commons.Ovh 7 | import io.kotlintest.specs.FunSpec 8 | import io.reactivex.Observable 9 | import io.vertx.kotlin.core.json.Json 10 | import io.vertx.reactivex.core.Vertx 11 | import io.vertx.reactivex.ext.web.client.WebClient 12 | import java.util.concurrent.TimeUnit 13 | 14 | class DnsManagerTest: FunSpec() { 15 | 16 | init { 17 | testCreateRecord() 18 | } 19 | 20 | private fun testCreateRecord() = test("Create record to ovh") { 21 | 22 | // val letsAutomateConfig = LetsAutomateConfig( 23 | // ovh = Ovh( 24 | // applicationKey = "oNWDI156akva3YdN", 25 | // applicationSecret = "sUWJj7N8e0CdyeE0WH06xcyfiCl7Proj", 26 | // consumerKey = "rmrfup2lKqjF3ZyzZJ5YIaOsOD9CD4aa", 27 | // redirectHost = "NA", 28 | // host = "https://api.ovh.com" 29 | // ), 30 | // letSEncrypt = LetSEncryptConfig("", "maif")) 31 | // val vertx = Vertx.vertx() 32 | // val client = WebClient.create(vertx) 33 | // val dnsManager = OvhDnsManager(client, vertx.createDnsClient(), letsAutomateConfig) 34 | 35 | 36 | // val message: Domain = dnsManager.listDomains().toList().blockingGet().get(0) 37 | // println(message) 38 | 39 | // 40 | // try { 41 | // 42 | // val recordCreated: Either<Error, Domain> = dnsManager.createRecord("adelegue.com", Record( 43 | // name = null, 44 | // target = "test", 45 | // subDomain = "tutu", 46 | // fieldType = "TXT", 47 | // ttl = 0 48 | // )).blockingGet() 49 | // if (recordCreated.isRight) { 50 | // println(recordCreated.right().get()) 51 | // } else { 52 | // println(recordCreated.left().get()) 53 | // } 54 | // } catch (e: Exception) { 55 | // e.printStackTrace() 56 | // } 57 | 58 | 59 | // try { 60 | // 61 | // val recordCreated: Either<Error, Domain> = dnsManager.updateRecord("adelegue.com", 1546392325, Record( 62 | // name = 1546392325, 63 | // target = "test", 64 | // subDomain = "_acme-challenge", 65 | // fieldType = "TXT", 66 | // ttl = 0 67 | // )).blockingGet() 68 | // 69 | // if (recordCreated.isRight) { 70 | // println(recordCreated.right().get()) 71 | // } else { 72 | // println(recordCreated.left().get()) 73 | // } 74 | // 75 | // } catch (e: Exception) { 76 | // e.printStackTrace() 77 | // } 78 | 79 | 80 | // val check = Observable 81 | // .interval(2, TimeUnit.SECONDS) 82 | // .take(6, TimeUnit.HOURS) 83 | // .flatMap { _ -> 84 | // println("New DNS check") 85 | // dnsManager 86 | // .checkRecord("adelegue.com", Record(null, "", 0, "TXT", "tutu")) 87 | // .onErrorReturnItem(emptyList()) 88 | // .toObservable() 89 | // } 90 | // .filter { l -> l.isNotEmpty() } 91 | // .take(1) 92 | // .singleOrError() 93 | // .blockingGet() 94 | // println(check) 95 | 96 | } 97 | 98 | 99 | } -------------------------------------------------------------------------------- /src/test/java/fr/maif/automate/matcher.kt: -------------------------------------------------------------------------------- 1 | package fr.maif.automate 2 | 3 | import arrow.core.Either 4 | import arrow.core.orNull 5 | import fr.maif.automate.certificate.write.CertificateEventStore 6 | import fr.maif.automate.certificate.write.State 7 | import fr.maif.automate.commons.Error 8 | import fr.maif.automate.commons.stringify 9 | import io.kotlintest.forAll 10 | import io.kotlintest.* 11 | import io.kotlintest.matchers.* 12 | 13 | 14 | public infix fun CertificateEventStore.shouldHaveState(state: State.CertificateState) { 15 | val data = this.state().blockingGet().data.values 16 | forAll(data){ d -> 17 | val (domain, subdomain, wildcard, _, privateKey, csr, certificate) = d 18 | val (domain1, subdomain1, wildcard1, _, privateKey1, csr1, certificate1) = state 19 | domain shouldBe domain1 20 | subdomain shouldBe subdomain1 21 | wildcard shouldBe wildcard1 22 | privateKey?.stringify() shouldBe privateKey1?.stringify() 23 | csr shouldBe csr1 24 | certificate?.certificate shouldBe certificate1?.certificate 25 | certificate?.chain shouldBe certificate1?.chain 26 | } 27 | } 28 | 29 | public infix fun <T> Either<Error, T>.shouldBe(expected: T): Unit { 30 | when(this) { 31 | is Either.Right -> this.b shouldBe expected 32 | is Either.Left -> fail("Should be Right") 33 | } 34 | } 35 | 36 | public infix fun <T> Either<Error, T>.shouldBe(expected: (T?) -> Unit): Unit { 37 | this should beInstanceOf(Either.Right::class) 38 | expected(this.orNull()) 39 | } 40 | 41 | public infix fun <T> Either<Error, T>.shouldBeError(expected: Error): Unit { 42 | when(this) { 43 | is Either.Left -> this.a shouldBe expected 44 | is Either.Right -> fail("Should be Left") 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | <configuration> 2 | 3 | <appender name="FILE" class="ch.qos.logback.core.FileAppender"> 4 | <file>${application.home:-.}/logs/application.log</file> 5 | <encoder> 6 | <pattern>%date [%level] from %logger in %thread - %message%n%xException</pattern> 7 | </encoder> 8 | </appender> 9 | 10 | <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> 11 | <encoder> 12 | <pattern>%logger{15} - %message%n%xException{10}</pattern> 13 | </encoder> 14 | </appender> 15 | 16 | <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender"> 17 | <appender-ref ref="FILE"/> 18 | </appender> 19 | 20 | <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender"> 21 | <appender-ref ref="STDOUT"/> 22 | </appender> 23 | 24 | <logger name="fr.maif.automate" level="DEBUG"/> 25 | 26 | <root level="DEBUG"> 27 | <appender-ref ref="ASYNCSTDOUT"/> 28 | </root> 29 | 30 | </configuration> --------------------------------------------------------------------------------