diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5e04587dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target/** +build/** +bin/** +.idea/** +.history/** +.github/** +.git/** diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8fc5677e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Set line endings to LF, even on Windows. Otherwise, execution within Docker fails. +# See https://help.github.com/articles/dealing-with-line-endings/ +*.sh text eol=lf +gradlew text eol=lf +*.cmd text eol=crlf +*.bat text eol=crlf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c8398bda9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..58fa77f03 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base", + ":preserveSemverRanges", + ":rebaseStalePrs", + ":disableRateLimiting", + ":semanticCommits", + ":semanticCommitTypeAll(renovatebot)" + ], + "labels": ["dependencies", "bot"] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..f0c16d12b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +name: Build + +env: + JAVA_OPTS: "-Xms512m -Xmx6048m -Xss128m -XX:ReservedCodeCacheSize=512m -XX:+UseG1GC" + GRADLE_OPTS: "-Xms512m -Xmx6048m -Xss128m -XX:ReservedCodeCacheSize=512m -XX:+UseG1GC" + TERM: xterm-256color + +on: + push: + branches: [ 7.0 ] + pull_request: + branches: [ 7.0 ] + +concurrency: + group: "workflow = ${{ github.workflow }}, ref = ${{ github.event.ref }}" + cancel-in-progress: ${{ github.event_name == 'push' }} + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest, windows-latest ] + jdk: [ 21 ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up JDK ${{ matrix.jdk }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.jdk }} + distribution: 'corretto' + - name: Build with JDK ${{ matrix.jdk }} on ${{ matrix.os }} + run: ./gradlew build + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..1d6df4a3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +.classpath +!/.project +.project +.settings +.history +.vscode +target/ +.idea/ +.DS_Store +.idea +overlays/ +.gradle/ +build/ +log/ +bin/ +*.war +*.iml +*.log +tmp/ +./apache-tomcat +apache-tomcat.zip +config-metadata.properties +node-modules +package-lock.json \ No newline at end of file diff --git a/.java-version b/.java-version new file mode 100644 index 000000000..74623ac8d --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21.0 \ No newline at end of file diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 000000000..6b6985e17 --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=21 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ddec5fee8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +ARG BASE_IMAGE="azul/zulu-openjdk:21" + +FROM $BASE_IMAGE AS overlay + +ARG EXT_BUILD_COMMANDS="" +ARG EXT_BUILD_OPTIONS="" + +RUN mkdir -p cas-overlay +COPY ./src cas-overlay/src/ +COPY ./gradle/ cas-overlay/gradle/ +COPY ./gradlew ./settings.gradle ./build.gradle ./gradle.properties ./lombok.config /cas-overlay/ + +RUN mkdir -p ~/.gradle \ + && echo "org.gradle.daemon=false" >> ~/.gradle/gradle.properties \ + && echo "org.gradle.configureondemand=true" >> ~/.gradle/gradle.properties \ + && cd cas-overlay \ + && chmod 750 ./gradlew \ + && ./gradlew --version; + +RUN cd cas-overlay \ + && ./gradlew clean build $EXT_BUILD_COMMANDS --parallel --no-daemon -Pexecutable=false $EXT_BUILD_OPTIONS; + +RUN cd cas-overlay \ + && java -Djarmode=tools -jar build/libs/cas.war extract \ + && java -XX:ArchiveClassesAtExit=./cas/cas.jsa -Dspring.context.exit=onRefresh -jar cas/cas.war + +FROM $BASE_IMAGE AS cas + +LABEL "Organization"="Apereo" +LABEL "Description"="Apereo CAS" + +RUN cd / \ + && mkdir -p /etc/cas/config \ + && mkdir -p /etc/cas/services \ + && mkdir -p /etc/cas/saml \ + && mkdir -p cas-overlay; + +COPY --from=overlay cas-overlay/cas cas-overlay/cas/ + +COPY etc/cas/ /etc/cas/ +COPY etc/cas/config/ /etc/cas/config/ +COPY etc/cas/services/ /etc/cas/services/ +COPY etc/cas/saml/ /etc/cas/saml/ + +EXPOSE 8080 8443 + +ENV PATH $PATH:$JAVA_HOME/bin:. + +WORKDIR cas-overlay +ENTRYPOINT ["java", "-server", "-noverify", "-Xmx2048M", "-XX:SharedArchiveFile=cas/cas.jsa", "-jar", "cas/cas.war"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Procfile b/Procfile new file mode 100644 index 000000000..2c732c313 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: java $JAVA_OPTS -jar build/libs/cas.war --server.port=$PORT --server.ssl.enabled=false diff --git a/README.md b/README.md new file mode 100644 index 000000000..17541b849 --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +Apereo CAS WAR Overlay Template +===================================== + +WAR Overlay Type: `cas-overlay` + +# Versions + +- CAS Server `7.0.8` +- JDK `21` + +# Build + +To build the project, use: + +```bash +# Use --refresh-dependencies to force-update SNAPSHOT versions +./gradlew[.bat] clean build +``` + +To see what commands/tasks are available to the build script, run: + +```bash +./gradlew[.bat] tasks +``` + +If you need to, on Linux/Unix systems, you can delete all the existing artifacts +(artifacts and metadata) Gradle has downloaded using: + +```bash +# Only do this when absolutely necessary +rm -rf $HOME/.gradle/caches/ +``` + +Same strategy applies to Windows too, provided you switch `$HOME` to its equivalent in the above command. + +# Keystore + +For the server to run successfully, you might need to create a keystore file. +This can either be done using the JDK's `keytool` utility or via the following command: + +```bash +./gradlew[.bat] createKeystore +``` + +Use the password `changeit` for both the keystore and the key/certificate entries. +Ensure the keystore is loaded up with keys and certificates of the server. + +## Extension Modules + +Extension modules may be specified under the `dependencies` block of the [Gradle build script](build.gradle): + +```gradle +dependencies { + implementation "org.apereo.cas:cas-server-some-module" + ... +} +``` + +To collect the list of all project modules and dependencies in the overlay: + +```bash +./gradlew[.bat] dependencies +``` + +# Deployment + +On a successful deployment via the following methods, the server will be available at: + +* `https://localhost:8443/cas` + + +## Executable WAR + +Run the server web application as an executable WAR. Note that running an executable WAR requires CAS to use an embedded container such as Apache Tomcat, Jetty, etc. + +The current servlet container is specified as `-tomcat`. + +```bash +java -jar build/libs/cas.war +``` + +Or via: + +```bash +./gradlew[.bat] run +``` + +It is often an advantage to explode the generated web application and run it in unpacked mode. +One way to run an unpacked archive is by starting the appropriate launcher, as follows: + +```bash +jar -xf build/libs/cas.war +cd build/libs +java org.springframework.boot.loader.launch.JarLauncher +``` + +This is slightly faster on startup (depending on the size of the WAR file) than +running from an unexploded archive. After startup, you should not expect any differences. + +Debug the CAS web application as an executable WAR: + +```bash +./gradlew[.bat] debug +``` + +Or via: + +```bash +java -Xdebug -Xrunjdwp:transport=dt_socket,address=5000,server=y,suspend=y -jar build/libs/cas.war +``` + +Run the CAS web application as a *standalone* executable WAR: + +```bash +./gradlew[.bat] clean executable +``` + +### CDS Support + +CDS is a JVM feature that can help reduce the startup time and memory footprint of Java applications. CAS via Spring Boot +now has support for easy creation of a CDS friendly layout. This layout can be created by extracting the CAS web application file +with the help of the `tools` jarmode: + +```bash +# Note: +# You must first build the web application with "executable" turned off +java -Djarmode=tools -jar build/libs/cas.war extract + +# Perform a training run once +java -XX:ArchiveClassesAtExit=cas.jsa -Dspring.context.exit=onRefresh -jar cas/cas.war + +# Run the CAS web application via CDS +java XX:SharedArchiveFile=cas.jsa -jar cas/cas.war +``` + +## External + +Deploy the binary web application file in `build/libs` after a successful build to a servlet container of choice. + +# Docker + +The following strategies outline how to build and deploy CAS Docker images. + +## Jib + +The overlay embraces the [Jib Gradle Plugin](https://github.com/GoogleContainerTools/jib) to provide easy-to-use out-of-the-box tooling for building CAS docker images. Jib is an open-source Java containerizer from Google that lets Java developers build containers using the tools they know. It is a container image builder that handles all the steps of packaging your application into a container image. It does not require you to write a Dockerfile or have Docker installed, and it is directly integrated into the overlay. + +```bash +# Running this task requires that you have Docker installed and running. +./gradlew build jibDockerBuild +``` + +## Dockerfile + +You can also use the Docker tooling and the provided `Dockerfile` to build and run. +There are dedicated Gradle tasks available to build and push Docker images using the supplied `DockerFile`: + +```bash +./gradlew build casBuildDockerImage +``` + +Once ready, you may also push the images: + +```bash +./gradlew casPushDockerImage +``` + +If credentials (username+password) are required for pull and push operations, they may be specified +using system properties via `-DdockerUsername=...` and `-DdockerPassword=...`. + +A `docker-compose.yml` is also provided to orchestrate the build: + +```bash +docker-compose build +``` + + +## Spring Boot + +You can use the Spring Boot build plugin for Gradle to create CAS container images. +The plugins create an OCI image (the same format as one created by docker build) +by using [Cloud Native Buildpacks](https://buildpacks.io/). You do not need a Dockerfile, but you do need a Docker daemon, +either locally (which is what you use when you build with docker) or remotely +through the `DOCKER_HOST` environment variable. The default builder is optimized for +Spring Boot applications such as CAS, and the image is layered efficiently. + +```bash +./gradlew bootBuildImage +``` + +The first build might take a long time because it has to download some container +images and the JDK, but subsequent builds should be fast. + + +# CAS Command-line Shell + +To launch into the CAS command-line shell: + +```bash +./gradlew[.bat] downloadShell runShell +``` + +# Retrieve Overlay Resources + +To fetch and overlay a CAS resource or view, use: + +```bash +./gradlew[.bat] getResource -PresourceName=[resource-name] +``` + +# Create User Interface Themes Structure + +You can use the overlay to construct the correct directory structure for custom user interface themes: + +```bash +./gradlew[.bat] createTheme -Ptheme=redbeard +``` + +The generated directory structure should match the following: + +``` +├── redbeard.properties +├── static +│ └── themes +│ └── redbeard +│ ├── css +│ │ └── cas.css +│ └── js +│ └── cas.js +└── templates + └── redbeard + └── fragments +``` + +HTML templates and fragments can be moved into the above directory structure, +and the theme may be assigned to applications for use. + +# List Overlay Resources + +To list all available CAS views and templates: + +```bash +./gradlew[.bat] listTemplateViews +``` + +To unzip and explode the CAS web application file and the internal resources jar: + +```bash +./gradlew[.bat] explodeWar +``` + +# Configuration + +- The `etc` directory contains the configuration files and directories that need to be copied to `/etc/cas/config`. + +```bash +./gradlew[.bat] copyCasConfiguration +``` + +- The specifics of the build are controlled using the `gradle.properties` file. + +## Configuration Metadata + +Configuration metadata allows you to export collection of CAS properties as a report into a file +that can later be examined. You will find a full list of CAS settings along with notes, types, default and accepted values: + +```bash +./gradlew exportConfigMetadata +``` + +# Puppeteer + +> [Puppeteer](https://pptr.dev/) is a Node.js library which provides a high-level API to control Chrome/Chromium over the DevTools Protocol. +> Puppeteer runs in headless mode by default, but can be configured to run in full (non-headless) Chrome/Chromium. + +Puppeteer scenarios, used here as a form of acceptance testing, allow you to verify CAS functionality to address a particular authentication flow. The scenarios, which may be +found inside the `./puppeteer/scenarios` directory are designed as small Node.js scripts that spin up a headless browser and walk through a test scenario. You may +design your own test scenarios that verify functionality specific to your CAS deployment or feature. + +To execute Puppeteer scenarios, run: + +```bash +./puppeteer/run.sh +``` + +This will first attempt to build your CAS deployment, will install Puppeteer and all other needed libraries. It will then launch the CAS server, +and upon its availability, will iterate through defined scenarios and will execute them one at a time. + +The following defaults are assumed: + +- CAS will be available at `https://localhost:8443/cas/login`. +- The CAS overlay is prepped with an embedded server container, such as Apache Tomcat. + +You may of course need to make adjustments to account for your specific environment and deployment settings, URLs, etc. + + +# Duct + +`duct` is a Gradle task to do quick smoke tests of multi-node CAS high-availability deployments. In particular, it tests correctness of ticket +sharing between multiple individual CAS server nodes backed by distributed ticket registries such as Hazelcast, Redis, etc. + +This task requires CAS server nodes to **enable the CAS REST module**. It will **NOT** work without it. + +The task accepts the following properties: + +- Arbitrary number of CAS server nodes specified via the `duct.cas.X` properties. +- URL of the service application registered with CAS specified via `duct.service`, for which tickets will be requested. +- `duct.username` and `duct.password` to use for authentication, when requesting ticket-granting tickets. + +It automates the following scenario: + +- Authenticate and issue a service ticket on one CAS node +- Validate this service ticket on the another node +- Repeat (You may cancel and stop the task at any time with `Ctrl+C`) + +If the task succeeds, then we effectively have proven that the distributed ticket registry has been set up and deployed +correctly and that there are no connectivity issues between CAS nodes. + +To run the task, you may use: + +```bash +./gradlew duct + -Pduct.cas.1=https://node1.example.org/cas \ + -Pduct.cas.2=https://node2.example.org/cas \ + -Pduct.cas.3=https://node3.example.org/cas \ + -Pduct.cas.4=https://node4.example.org/cas \ + -Pduct.service=https://apereo.github.io \ + -Pduct.username=casuser \ + -Pduct.password=Mellon +``` + +You may also supply the following options: + +- `duct.debug`: Boolean flag to output debug and verbose logging. +- `duct.duration`: Number of seconds, i.e. `30` to execute the scenario. +- `duct.count`: Number of iterations, i.e. `5` to execute the scenario. + + +# OpenRewrite + +[OpenRewrite](https://docs.openrewrite.org/) is a tool used by the CAS in form of a Gradle plugin +that allows the project to upgrade in place. It works by making changes to the project structure representing +your CAS build and printing the modified files back. Modifications are packaged together in form of upgrade +scripts called `Recipes` that are automatically packaged and presented to the build and may be discovered via: + +```bash +./gradlew --init-script openrewrite.gradle rewriteDiscover -PtargetVersion=X.Y.Z --no-configuration-cache | grep "org.apereo.cas" +``` + +**NOTE:** All CAS specific recipes begin with `org.apereo.cas`. The `targetVersion` must be the CAS version to which you want to upgrade. + +OpenRewrite recipes make minimally invasive changes to your CAS build allowing you to upgrade from one version +to the next with minimal effort. The recipe contains *almost* everything that is required for a CAS build system to navigate +from one version to other and automated tedious aspects of the upgrade such as finding the correct versions of CAS, +relevant libraries and plugins as well as any possible structural changes to one's CAS build. + +To run, you will need to find and select the name of the recipe first. Then, you can dry-run the selected recipes and see which files would be changed in the build log. +This does not alter your source files on disk at all. This goal can be used to preview the changes that would be made by the active recipes. + +```bash +./gradlew --init-script openrewrite.gradle rewriteDryRun -PtargetVersion=X.Y.Z -DactiveRecipe=[recipe name] --no-configuration-cache +``` + +When you are ready, you can run the actual recipe: + +```bash +./gradlew --init-script openrewrite.gradle rewriteRun -PtargetVersion=X.Y.Z -DactiveRecipe=[recipe name] --no-configuration-cache +``` + +This will run the selected recipes and apply the changes. This will write changes locally to your source files on disk. +Afterward, review the changes, and when you are comfortable with the changes, commit them. +The run goal generates warnings in the build log wherever it makes changes to source files. + diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..cd1dae1b4 --- /dev/null +++ b/build.gradle @@ -0,0 +1,304 @@ +import org.apache.tools.ant.taskdefs.condition.* +import org.gradle.internal.logging.text.* +import org.apereo.cas.metadata.* +import java.nio.file.* +import java.lang.reflect.* +import org.gradle.internal.logging.text.* +import static org.gradle.internal.logging.text.StyledTextOutput.Style + +buildscript { + repositories { + if (project.privateRepoUrl) { + maven { + url project.privateRepoUrl + credentials { + username = project.privateRepoUsername + password = System.env.PRIVATE_REPO_TOKEN + } + } + } + mavenLocal() + mavenCentral() + gradlePluginPortal() + maven { + url 'https://oss.sonatype.org/content/repositories/snapshots' + mavenContent { snapshotsOnly() } + } + maven { + url "https://repo.spring.io/milestone" + mavenContent { releasesOnly() } + } + } + dependencies { + classpath "org.springframework.boot:spring-boot-gradle-plugin:${project.springBootVersion}" + classpath "io.freefair.gradle:maven-plugin:${project.gradleFreeFairPluginVersion}" + classpath "io.freefair.gradle:lombok-plugin:${project.gradleFreeFairPluginVersion}" + classpath "com.google.cloud.tools:jib-gradle-plugin:${project.jibVersion}" + classpath "com.bmuschko:gradle-docker-plugin:${project.gradleDockerPluginVersion}" + classpath "de.undercouch:gradle-download-task:${project.gradleDownloadTaskVersion}" + classpath "org.apereo.cas:cas-server-core-api-configuration-model:${project.'cas.version'}" + classpath "org.apereo.cas:cas-server-core-configuration-metadata-repository:${project.'cas.version'}" + } +} + +repositories { + if (project.privateRepoUrl) { + maven { + url project.privateRepoUrl + credentials { + username = project.privateRepoUsername + password = System.env.PRIVATE_REPO_TOKEN + } + } + } + mavenLocal() + mavenCentral() + maven { url 'https://oss.sonatype.org/content/repositories/releases' } + maven { + url 'https://oss.sonatype.org/content/repositories/snapshots' + mavenContent { snapshotsOnly() } + } + maven { + url "https://repository.apache.org/content/repositories/snapshots" + mavenContent { snapshotsOnly() } + } + maven { + url 'https://build.shibboleth.net/nexus/content/repositories/releases/' + mavenContent { releasesOnly() } + } + maven { + url "https://build.shibboleth.net/nexus/content/repositories/snapshots" + mavenContent { snapshotsOnly() } + } + maven { + url "https://repo.spring.io/milestone" + mavenContent { releasesOnly() } + } +} + +apply plugin: "io.freefair.war-overlay" +apply plugin: "war" + +apply plugin: "org.springframework.boot" +apply plugin: "io.freefair.lombok" + + + +apply from: rootProject.file("gradle/springboot.gradle") +apply plugin: "com.google.cloud.tools.jib" +apply plugin: "com.bmuschko.docker-remote-api" +apply from: rootProject.file("gradle/tasks.gradle") + +def out = services.get(StyledTextOutputFactory).create("cas") + +configurations { + all { + resolutionStrategy { + cacheChangingModulesFor 0, "seconds" + cacheDynamicVersionsFor 0, "seconds" + preferProjectModules() + def failIfConflict = project.hasProperty("failOnVersionConflict") && Boolean.valueOf(project.getProperty("failOnVersionConflict")) + if (failIfConflict) { + failOnVersionConflict() + } + + if (project.hasProperty("tomcatVersion")) { + eachDependency { DependencyResolveDetails dependency -> + def requested = dependency.requested + if (requested.group.startsWith("org.apache.tomcat") && requested.name != "jakartaee-migration") { + dependency.useVersion("${tomcatVersion}") + } + } + } + } + exclude(group: "cglib", module: "cglib") + exclude(group: "cglib", module: "cglib-full") + exclude(group: "org.slf4j", module: "slf4j-log4j12") + exclude(group: "org.slf4j", module: "slf4j-simple") + exclude(group: "org.slf4j", module: "jcl-over-slf4j") + exclude(group: "org.apache.logging.log4j", module: "log4j-to-slf4j") + } +} + +war { + entryCompression = ZipEntryCompression.STORED + enabled = false +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(project.targetCompatibility) + def chosenJvmVendor = null + JvmVendorSpec.declaredFields.each { field -> + if (field.type == JvmVendorSpec && Modifier.isStatic(field.getModifiers())) { + if (field.name == project.jvmVendor?.toUpperCase()) { + chosenJvmVendor = field.get(null) + return + } + } + } + if (chosenJvmVendor != null) { + vendor = chosenJvmVendor + out.withStyle(Style.Success).println("Using ${chosenJvmVendor} as the JVM vendor for the Java toolchain") + } else { + out.withStyle(Style.Info).println("JVM vendor ${project.jvmVendor} is not recognized") + } + } +} + +bootBuildImage { + imageName = "${project.'containerImageOrg'}/${project.'containerImageName'}:${project.version}" +} + + +['jibDockerBuild', 'jibBuildTar', 'jib'].each { taskName -> + if (gradle.gradleVersion >= "8.0") { + getTasksByName(taskName, true).each(it -> { + it.notCompatibleWithConfigurationCache("Jib is not compatible with configuration cache"); + it.enabled = !gradle.startParameter.isConfigurationCacheRequested() + }) + } +} + +def imagePlatforms = project.dockerImagePlatform.split(",") +def dockerUsername = providers.systemProperty("dockerUsername").getOrNull() +def dockerPassword = providers.systemProperty("dockerPassword").getOrNull() +def imageTagPostFix = providers.systemProperty("dockerImageTagPostfix").getOrElse("") + +jib { + if (gradle.gradleVersion >= "8.0" && gradle.startParameter.isConfigurationCacheRequested()) { + out.withStyle(Style.Info).println("You are seeing this message because the Gradle configuration cache is turned on") + out.withStyle(Style.Info).println("Running Jib tasks to produce Docker images will require the command-line option: --no-configuration-cache") + out.withStyle(Style.Info).println("Jib does not support the Gradle configuration cache; Please see https://github.com/GoogleContainerTools/jib/issues/3132") + out.withStyle(Style.Info).println("Jib tasks are disabled.") + } + from { + image = project.baseDockerImage + platforms { + imagePlatforms.each { + def given = it.split(":") + platform { + architecture = given[0] + os = given[1] + } + } + } + } + to { + image = "${project.'containerImageOrg'}/${project.'containerImageName'}:${project.version}" + /** + ecr-login: Amazon Elastic Container Registry (ECR) + gcr: Google Container Registry (GCR) + osxkeychain: Docker Hub + */ + credHelper = "osxkeychain" + if (dockerUsername != null && dockerPassword != null) { + auth { + username = "${dockerUsername}" + password = "${dockerPassword}" + } + } + tags = [project.version] + } + container { + creationTime = "USE_CURRENT_TIMESTAMP" + entrypoint = ['/docker/entrypoint.sh'] + ports = ['80', '443', '8080', '8443', '8444', '8761', '8888', '5000'] + labels = [version:project.version, name:project.name, group:project.group, org:project.containerImageOrg] + workingDirectory = '/docker/cas/war' + } + extraDirectories { + paths { + path { + from = file('src/main/jib') + } + path { + from = file('etc/cas') + into = '/etc/cas' + } + path { + from = file("build/libs") + into = "/docker/cas/war" + } + } + permissions = [ + '/docker/entrypoint.sh': '755' + ] + } + allowInsecureRegistries = project.allowInsecureRegistries +} + +import com.bmuschko.gradle.docker.tasks.image.* +tasks.register("casBuildDockerImage", DockerBuildImage) { + dependsOn("build") + + def imageTag = "${project.'cas.version'}" + inputDir = project.projectDir + images.add("apereo/cas:${imageTag}${imageTagPostFix}") + images.add("apereo/cas:latest${imageTagPostFix}") + if (dockerUsername != null && dockerPassword != null) { + username = dockerUsername + password = dockerPassword + } + doLast { + out.withStyle(Style.Success).println("Built CAS images successfully.") + } +} + +tasks.register("casPushDockerImage", DockerPushImage) { + dependsOn("casBuildDockerImage") + + def imageTag = "${project.'cas.version'}" + images.add("apereo/cas:${imageTag}${imageTagPostFix}") + images.add("apereo/cas:latest${imageTagPostFix}") + + if (dockerUsername != null && dockerPassword != null) { + username = dockerUsername + password = dockerPassword + } + doLast { + out.withStyle(Style.Success).println("Pushed CAS images successfully.") + } +} + + +if (project.hasProperty("appServer")) { + def appServer = project.findProperty('appServer') ?: '' + out.withStyle(Style.Success).println("Building CAS version ${project.version} with application server ${appServer}") +} else { + out.withStyle(Style.Success).println("Building CAS version ${project.version} without an application server") +} + +dependencies { + /** + * Do NOT modify the lines below or else you will risk breaking dependency management. + **/ + implementation enforcedPlatform("org.apereo.cas:cas-server-support-bom:${project.'cas.version'}") + implementation platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) + + /** + * Do NOT modify the lines below or else you will risk breaking the build. + **/ + implementation "org.apereo.cas:cas-server-core-api-configuration-model" + implementation "org.apereo.cas:cas-server-webapp-init" + + if (appServer == 'tomcat') { + implementation "org.apereo.cas:cas-server-webapp-init-tomcat" + } + + developmentOnly "org.springframework.boot:spring-boot-devtools:${project.springBootVersion}" + + /** + * CAS dependencies and modules may be listed here. + * + * There is no need to specify the version number for each dependency + * since versions are all resolved and controlled by the dependency management + * plugin via the CAS bom. + **/ + implementation "org.apereo.cas:cas-server-support-rest" + + + + testImplementation "org.springframework.boot:spring-boot-starter-test" +} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..8f2e6ca7c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: '3' +services: + cas: + build: . + ports: + - "8443:8443" + - "8080:8080" \ No newline at end of file diff --git a/etc/cas/.ignore b/etc/cas/.ignore new file mode 100644 index 000000000..e69de29bb diff --git a/etc/cas/config/log4j2.xml b/etc/cas/config/log4j2.xml new file mode 100644 index 000000000..f5634a7aa --- /dev/null +++ b/etc/cas/config/log4j2.xml @@ -0,0 +1,167 @@ + + + + + + /var/log + info + warn + info + warn + warn + warn + warn + warn + warn + warn + warn + true + false + + casStackTraceFile + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..31fe69c1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,113 @@ +# This overlay project's version +# For consistency and with no other effect, this is set to the CAS version itself. +version=7.0.8 + +# This is the CAS server version that will be deployed. +# Versions typically are in the format of: +# [Major].[Minor].[Patch].[Security] +# For patch and security releases and unless explicitly stated otherwise, the below property +# and NOTHING ELSE is the only change you usually need to make to upgrade your CAS deployment. +cas.version=7.0.8 + +# The Spring Boot version is very much tied to the CAS release 7.0.8 +# and must not be modified or upgraded out of band, as doing so would most likely +# jeopardize the stability of your CAS deployment leading to unpredictable behavior. +springBootVersion=3.2.1 + +# The coordinates of this overlay project +group=org.apereo.cas +artifactId=cas-overlay + +# Before changing the JDK versions here, you must account for the following: +# - Dependency Compatibility: Ensure that all libraries and frameworks you use are compatible with Java 21 and above. +# - Environment Compatibility: Check that your deployment environments (e.g., servers, CI/CD pipelines, cloud services) support Java 21 and above. +# Remember that this CAS build does and will only officially support Java 21. Do NOT change platform requirements unless +# you really know what you are doing and are comfortable managing the deployment and its risks completely on your own. + +# This property defines the version of Java that your source code is written in. +# It ensures that your code is compatible with the specified version of the Java language. +# Gradle will expect your source code to be compatible with JDK 21. +sourceCompatibility=21 + +# This property specifies the version of the Java runtime that the compiled bytecode should be compatible with. +# It ensures that the bytecode generated by the compiler will run on JDK 21. +targetCompatibility=21 + +# This property controls the JVM vendor that is used by Gradle toolchains. +# You may want to build CAS using a Java version that is not supported for running Gradle +# by setting this property to the vendor of the JDK you want to use. +# If Gradle can’t find a locally available toolchain that matches the requirements +# of the build, it can automatically download one. +# Options include: AMAZON, ADOPTIUM, JETBRAINS, MICROSOFT, ORACLE, SAP, BELLSOFT, etc. +# Setting this to a blank or invalid value will force Gradle to use the JDK installation on the build machine. +jvmVendor=AMAZON + +# This plugin controls how JDK distributions required by the Grtadle toolchain +# are discovered, and downloaded when necessary. +# Note that auto-provisioning of a JDK distribution only kicks in when auto-detection fails +# to find a matching JDK, and auto-provisioning can only download new JDKs and is in no way +# involved in updating any of the already installed ones. +gradleFoojayPluginVersion=0.8.0 + +gradleFreeFairPluginVersion=8.10 + +# Used to build Docker images +jibVersion=3.4.3 +gradleDockerPluginVersion=9.4.0 + +# Specify the coordinates of the container image to build +containerImageOrg=apereo +containerImageName=cas + +baseDockerImage=azul/zulu-openjdk:21 +allowInsecureRegistries=false + +# Multiple platforms may be specified, separated by a comma i.e amd64:linux,arm64:linux +dockerImagePlatform=amd64:linux + +# Include a launch script for executable WAR artifact +# Setting this to true allows the final web application +# to be fully executable on its own. +executable=true + + +# Use -tomcat, -jetty, -undertow for deployment to control the embedded server container +# that will be used to deploy and manage your CAS deployment. +# You should set this to blank if you want to deploy to an external container. +# and want to set up, download and manage the container (i.e. Apache Tomcat) yourself. +appServer=-tomcat + +# If you are using an embedded Apache Tomcat container to deploy and run CAS, +# and need to override the Apache Tomcat version, uncomment the property below +# and specify the the Apache Tomcat version, i.e. 10.1.29. +# While enabled, this will override any and all upstream changes to +# Apache Tomcat dependency management and you will be directly responsible to make +# adjustments and upgrades as necessary. Use with caution, favor less work. +# tomcatVersion=10.1.29 + +# Settings to generate a keystore +# used by the build to assist with creating +# self-signed certificates for TLS +certDir=/etc/cas +serverKeystore=thekeystore +exportedServerCert=cas.crt +storeType=PKCS12 + +# Location of the downloaded CAS Shell JAR +shellDir=build/libs +ivyVersion=2.5.2 +gradleDownloadTaskVersion=4.1.1 + +# Include private repository +# override these in user properties or pass in values from env on command line +privateRepoUrl= +privateRepoUsername= + +# Gradle build settings +# Do NOT modify unless you know what you're doing! +org.gradle.configureondemand=true +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.jvmargs=-Xms1024m -Xmx4048m -XX:TieredStopAtLevel=1 +org.gradle.unsafe.configuration-cache=false +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/gradle/springboot.gradle b/gradle/springboot.gradle new file mode 100644 index 000000000..99b3db7e2 --- /dev/null +++ b/gradle/springboot.gradle @@ -0,0 +1,135 @@ +apply plugin: "java" + +sourceSets { + bootRunSources { + resources { + srcDirs new File("//etc/cas/templates/"), new File("${project.getProjectDir()}/src/main/resources/") + } + } +} + +configurations { + bootRunConfig { + extendsFrom compileClasspath + + exclude(group: "org.springframework.boot", module: "spring-boot-starter-logging") + exclude(group: "ch.qos.logback", module: "logback-core") + exclude(group: "ch.qos.logback", module: "logback-classic") + } +} + +dependencies { + bootRunConfig "org.apereo.cas:cas-server-core" + bootRunConfig "org.apereo.cas:cas-server-core-logging" + bootRunConfig "org.apereo.cas:cas-server-core-web" + bootRunConfig "org.apereo.cas:cas-server-core-webflow" + bootRunConfig "org.apereo.cas:cas-server-core-cookie" + bootRunConfig "org.apereo.cas:cas-server-core-logout" + bootRunConfig "org.apereo.cas:cas-server-core-authentication" + bootRunConfig "org.apereo.cas:cas-server-core-validation" + bootRunConfig "org.apereo.cas:cas-server-core-audit" + bootRunConfig "org.apereo.cas:cas-server-core-tickets" + bootRunConfig "org.apereo.cas:cas-server-core-services" + bootRunConfig "org.apereo.cas:cas-server-core-util" + + bootRunConfig "org.apereo.cas:cas-server-support-webconfig" + bootRunConfig "org.apereo.cas:cas-server-support-thymeleaf" + bootRunConfig "org.apereo.cas:cas-server-support-validation" + bootRunConfig "org.apereo.cas:cas-server-support-person-directory" + bootRunConfig "org.apereo.cas:cas-server-webapp-resources" + bootRunConfig "org.apereo.cas:cas-server-webapp-init" + bootRunConfig "org.apereo.cas:cas-server-webapp-tomcat" + bootRunConfig "org.apereo.cas:cas-server-webapp-init-tomcat" + + bootRunConfig "org.springframework.cloud:spring-cloud-starter-bootstrap" + bootRunConfig "org.springframework.boot:spring-boot-devtools" +} + +bootRun { + classpath = configurations.bootRunConfig + sourceSets.main.compileClasspath + sourceSets.main.runtimeClasspath + sourceResources sourceSets.bootRunSources + doFirst { + systemProperties = System.properties + } + + def list = [] + list.add("-XX:TieredStopAtLevel=1") + list.add("-Xverify:none") + list.add("--add-modules") + list.add("java.se") + list.add("--add-exports") + list.add("java.base/jdk.internal.ref=ALL-UNNAMED") + list.add("--add-opens") + list.add("java.base/java.lang=ALL-UNNAMED") + list.add("--add-opens") + list.add("java.base/java.nio=ALL-UNNAMED") + list.add("--add-opens") + list.add("java.base/sun.nio.ch=ALL-UNNAMED") + list.add("--add-opens") + list.add("java.management/sun.management=ALL-UNNAMED") + list.add("--add-opens") + list.add("jdk.management/com.sun.management.internal=ALL-UNNAMED") + list.add("-Xrunjdwp:transport=dt_socket,address=5000,server=y,suspend=n") + + jvmArgs = list + + def appArgList = ["--spring.thymeleaf.cache=false"] + args = appArgList +} + +springBoot { + mainClass = "org.apereo.cas.web.CasWebApplication" + +} + + +bootWar { + def executable = project.hasProperty("executable") && Boolean.valueOf(project.getProperty("executable")) + if (executable) { + logger.info "Including launch script for executable WAR artifact" + launchScript() + } else { + logger.info "WAR artifact is not marked as an executable" + } + + archiveFileName = "cas.war" + archiveBaseName = "cas" + + entryCompression = ZipEntryCompression.STORED + + /* + attachClasses = true + classesClassifier = 'classes' + archiveClasses = true + */ + + + overlays { + /* + https://docs.freefair.io/gradle-plugins/current/reference/#_io_freefair_war_overlay + Note: The "excludes" property is only for files in the war dependency. + If a jar is excluded from the war, it could be brought back into the final war as a dependency + of non-war dependencies. Those should be excluded via normal gradle dependency exclusions. + */ + cas { + from "org.apereo.cas:cas-server-webapp${project.findProperty('appServer') ?: ''}:${project.'cas.version'}@war" + + + provided = false + + def excludeArtifacts = ["WEB-INF/lib/servlet-api-2*.jar"] + if (project.hasProperty("tomcatVersion")) { + excludes += ["WEB-INF/lib/tomcat-*.jar"] + } + excludes = excludeArtifacts + + /* + excludes = ["WEB-INF/lib/somejar-1.0*"] + enableCompilation = true + includes = ["*.xyz"] + targetPath = "sub-path/bar" + skip = false + */ + } + } +} diff --git a/gradle/tasks.gradle b/gradle/tasks.gradle new file mode 100644 index 000000000..ad85cacde --- /dev/null +++ b/gradle/tasks.gradle @@ -0,0 +1,520 @@ +import static org.gradle.internal.logging.text.StyledTextOutput.Style + +import org.apereo.cas.metadata.* +import org.gradle.internal.logging.text.* + +import groovy.json.* +import groovy.time.* + +import java.nio.file.* +import java.util.* +import java.security.* + +buildscript { + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + maven { + url 'https://oss.sonatype.org/content/repositories/snapshots' + mavenContent { snapshotsOnly() } + } + maven { + url "https://repo.spring.io/milestone" + mavenContent { releasesOnly() } + } + } + dependencies { + classpath "org.apache.ivy:ivy:${project.ivyVersion}" + classpath "org.apereo.cas:cas-server-core-configuration-metadata-repository:${project.'cas.version'}" + } +} +apply plugin: "de.undercouch.download" + +task run(group: "build", description: "Run the CAS web application in embedded container mode") { + dependsOn 'build' + doLast { + def casRunArgs = Arrays.asList("-server -noverify -Xmx2048M -XX:+TieredCompilation -XX:TieredStopAtLevel=1".split(" ")) + project.javaexec { + jvmArgs = casRunArgs + classpath = project.files("build/libs/cas.war") + systemProperties = System.properties + logger.info "Started ${commandLine}" + } + } +} + +task setExecutable(group: "CAS", description: "Configure the project to run in executable mode") { + doFirst { + project.setProperty("executable", "true") + logger.info "Configuring the project as executable" + } +} + +task executable(type: Exec, group: "CAS", description: "Run the CAS web application in standalone executable mode") { + dependsOn setExecutable, 'build' + doFirst { + workingDir "." + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + commandLine "chmod", "+x", bootWar.archivePath + } + logger.info "Running ${bootWar.archivePath}" + commandLine bootWar.archivePath + } +} + + +task debug(group: "CAS", description: "Debug the CAS web application in embedded mode on port 5005") { + dependsOn 'build' + doLast { + logger.info "Debugging process is started in a suspended state, listening on port 5005." + def casArgs = Arrays.asList("-Xmx2048M".split(" ")) + project.javaexec { + jvmArgs = casArgs + debug = true + classpath = project.files("build/libs/cas.war") + systemProperties = System.properties + logger.info "Started ${commandLine}" + } + } +} + +task showConfiguration(group: "CAS", description: "Show configurations for each dependency, etc") { + doLast() { + def cfg = project.hasProperty("configuration") ? project.property("configuration") : "compile" + configurations.getByName(cfg).each { println it } + } +} + +task allDependenciesInsight(group: "build", type: DependencyInsightReportTask, description: "Produce insight information for all dependencies") {} + +task allDependencies(group: "build", type: DependencyReportTask, description: "Display a graph of all project dependencies") {} + +task casVersion(group: "CAS", description: "Display the current CAS version") { + doFirst { + def verbose = project.hasProperty("verbose") && Boolean.valueOf(project.getProperty("verbose")) + if (verbose) { + def out = services.get(StyledTextOutputFactory).create("CAS") + println "******************************************************************" + out.withStyle(Style.Info).println "Apereo CAS ${project.version}" + out.withStyle(Style.Description).println "Enterprise Single SignOn for all earthlings and beyond" + out.withStyle(Style.SuccessHeader).println "- GitHub: " + out.withStyle(Style.Success).println "https://github.com/apereo/cas" + out.withStyle(Style.SuccessHeader).println "- Docs: " + out.withStyle(Style.Success).println "https://apereo.github.io/cas" + out.withStyle(Style.SuccessHeader).println "- Blog: " + out.withStyle(Style.Success).println "https://apereo.github.io" + println "******************************************************************" + } else { + println project.version + } + } +} + +task springBootVersion(description: "Display current Spring Boot version") { + doLast { + println rootProject.springBootVersion + } +} + +task zip(type: Zip) { + from projectDir + exclude '**/.idea/**', '.gradle', 'tmp', '.git', '**/build/**', '**/bin/**', '**/out/**', '**/.settings/**' + destinationDirectory = buildDir + archiveFileName = "${project.name}.zip" + def zipFile = new File("${buildDir}/${archiveFileName}") + doLast { + if (zipFile.exists()) { + println "Zip archive is available at ${zipFile.absolutePath}" + } + } +} + +task createKeystore(group: "CAS", description: "Create CAS keystore") { + def dn = "CN=cas.example.org,OU=Example,OU=Org,C=US" + if (project.hasProperty("certificateDn")) { + dn = project.getProperty("certificateDn") + } + def subjectAltName = "dns:example.org,dns:localhost,ip:127.0.0.1" + if (project.hasProperty("certificateSubAltName")) { + subjectAltName = project.getProperty("certificateSubAltName") + } + + doFirst { + def certDir = project.getProperty("certDir") + def serverKeyStore = project.getProperty("serverKeystore") + def exportedServerCert = project.getProperty("exportedServerCert") + def storeType = project.getProperty("storeType") + def keystorePath = "$certDir/$serverKeyStore" + def serverCert = "$certDir/$exportedServerCert" + + mkdir certDir + // this will fail if thekeystore exists and has cert with cas alias already (so delete if you want to recreate) + logger.info "Generating keystore for CAS with DN ${dn}" + exec { + workingDir "." + commandLine "keytool", "-genkeypair", "-alias", "cas", + "-keyalg", "RSA", + "-keypass", "changeit", "-storepass", "changeit", + "-keystore", keystorePath, + "-dname", dn, "-ext", "SAN=${subjectAltName}", + "-storetype", storeType + } + logger.info "Exporting cert from keystore..." + exec { + workingDir "." + commandLine "keytool", "-exportcert", "-alias", "cas", + "-storepass", "changeit", "-keystore", keystorePath, + "-file", serverCert + } + logger.info "Import $serverCert into your Java truststore (\$JAVA_HOME/lib/security/cacerts)" + } +} + +task unzipWAR(type: Copy, group: "CAS", description: "Explodes the CAS web application archive") { + dependsOn 'build' + def destination = "${buildDir}/app" + + from zipTree("build/libs/cas.war") + into "${destination}" + doLast { + println "Unzipped WAR into ${destination}" + } +} + +task verifyRequiredJavaVersion { + def currentVersion = org.gradle.api.JavaVersion.current() + logger.info "Checking current Java version ${currentVersion} for required Java version ${project.targetCompatibility}" + def targetVersion = JavaVersion.toVersion(project.targetCompatibility) + if (!currentVersion.isCompatibleWith(targetVersion)) { + logger.warn("Careful: Current Java version ${currentVersion} does not match required Java version ${project.targetCompatibility}") + } +} + +task copyCasConfiguration(type: Copy, group: "CAS", + description: "Copy the CAS configuration from this project to /etc/cas/config") { + from "etc/cas/config" + into new File('/etc/cas/config').absolutePath + doFirst { + new File('/etc/cas/config').mkdirs() + } +} + + +def explodedDir = "${buildDir}/app" +def explodedResourcesDir = "${buildDir}/cas-resources" + +def resourcesJarName = "cas-server-webapp-resources" +def templateViewsJarName = "cas-server-support-thymeleaf" + +task unzip(type: Copy, group: "CAS", description: "Explodes the CAS archive and resources jar from the CAS web application archive") { + dependsOn unzipWAR + from zipTree("${explodedDir}/WEB-INF/lib/${templateViewsJarName}-${project.'cas.version'}.jar") + into explodedResourcesDir + + from zipTree("${explodedDir}/WEB-INF/lib/${resourcesJarName}-${project.'cas.version'}.jar") + into explodedResourcesDir + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + doLast { + println "Exploded WAR resources into ${explodedResourcesDir}" + } +} + +task downloadShell(group: "Shell", description: "Download CAS shell jar from snapshot or release maven repo", type: Download) { + def shellDir = project.providers.gradleProperty("shellDir").get() + def casVersion = project.providers.gradleProperty("cas.version").get() + def downloadFile + if (casVersion.contains("-SNAPSHOT")) { + def snapshotDir = "https://oss.sonatype.org/content/repositories/snapshots/org/apereo/cas/cas-server-support-shell/${casVersion}/" + def files = new org.apache.ivy.util.url.ApacheURLLister().listFiles(new URL(snapshotDir)) + files = files.sort { it.path } + files.each { + if (it.path.endsWith(".jar")) { + downloadFile = it + } + } + } else { + downloadFile = "https://repo1.maven.org/maven2/org/apereo/cas/cas-server-support-shell/${casVersion}/cas-server-support-shell-${casVersion}.jar" + } + new File("${shellDir}").mkdir() + logger.info "Downloading file: ${downloadFile}" + src downloadFile + dest new File("${shellDir}", "cas-server-support-shell-${casVersion}.jar") + overwrite false +} + +task runShell(group: "Shell", description: "Run the CAS shell") { + dependsOn downloadShell + def casVersion = project.providers.gradleProperty("cas.version").get() + doLast { + println "Run the following command to launch the shell:\n\tjava -jar ${project.shellDir}/cas-server-support-shell-${casVersion}.jar" + } +} + +task debugShell(group: "Shell", description: "Run the CAS shell with debug options, wait for debugger on port 5005") { + dependsOn downloadShell + def casVersion = project.providers.gradleProperty("cas.version").get() + doLast { + println """ + Run the following command to launch the shell:\n\t + java -Xrunjdwp:transport=dt_socket,address=5000,server=y,suspend=y -jar ${project.shellDir}/cas-server-support-shell-${casVersion}.jar + """ + } +} + +task listTemplateViews(group: "CAS", description: "List all CAS views") { + dependsOn unzip + + def templateViews = fileTree(explodedResourcesDir).matching { + include "**/*.html" + } + .collect { + return it.path.replace(explodedResourcesDir, "") + } + .toSorted() + + doFirst { + templateViews.each { println it } + } +} + +task getResource(group: "CAS", description: "Fetch a CAS resource and move it into the overlay") { + dependsOn unzip + + def resourceName = project.providers.gradleProperty("resourceName").getOrNull() + def resourcesDirectory = fileTree(explodedResourcesDir) + def projectDirectory = projectDir + + doFirst { + def results = resourcesDirectory.matching { + include "**/${resourceName}.*" + include "**/${resourceName}" + } + if (results.isEmpty()) { + println "No resources could be found matching ${resourceName}" + return + } + if (results.size() > 1) { + println "Multiple resources found matching ${resourceName}:\n" + results.each { + println "\t-" + it.path.replace(explodedResourcesDir, "") + } + println "\nNarrow down your search criteria and try again." + return + } + + def fromFile = explodedResourcesDir + def resourcesDir = "src/main/resources" + new File(resourcesDir).mkdir() + + def resourceFile = results[0].canonicalPath + def toResourceFile = new File("${projectDirectory}", resourceFile.replace(fromFile, resourcesDir)) + toResourceFile.getParentFile().mkdirs() + + Files.copy(Paths.get(resourceFile), Paths.get(toResourceFile.absolutePath), StandardCopyOption.REPLACE_EXISTING) + println "Copied file ${resourceFile} to ${toResourceFile}" + } +} + +task createTheme(group: "CAS", description: "Create theme directory structure in the overlay") { + def theme = project.providers.gradleProperty("theme").getOrNull() + + doFirst { + def builder = new FileTreeBuilder() + new File("src/main/resources/${theme}.properties").delete() + + builder.src { + main { + resources { + "static" { + themes { + "${theme}" { + css { + 'cas.css'('') + } + js { + 'cas.js'('') + } + images { + '.ignore'('') + } + } + } + } + + templates { + "${theme}" { + fragments { + + } + } + } + + "${theme}.properties"("""cas.standard.css.file=/themes/${theme}/css/cas.css +cas.standard.js.file=/themes/${theme}/js/cas.js + """) + } + } + } + } +} + +def skipValidation = project.hasProperty("validate") && project.property("validate").equals("false") +if (!skipValidation) { + task validateConfiguration(type: Copy, group: "CAS", + description: "Validate CAS configuration") { + def file = new File("${projectDir}/src/main/resources/application.properties") + if (file.exists()) { + throw new GradleException("This overlay project is overriding a CAS-supplied configuration file at ${file.path}. " + + "Overriding this file will disable all default CAS settings that are provided to the overlay, and " + + "generally has unintended side-effects. It's best to move your configuration inside an application.yml " + + "file, if you intend to keep the configuration bundled with the CAS web application. \n\nTo disable this " + + "validation step, run the build with -Pvalidate=false."); + } + } + processResources.dependsOn(validateConfiguration) +} + +task duct(group: "CAS", description: "Test ticket registry functionality via the CAS REST API") { + def service = project.findProperty("duct.service") ?: "https://apereo.github.io" + def casServerNodes = providers.gradlePropertiesPrefixedBy("duct.cas").get() + def username = project.findProperty("duct.username") ?: "casuser" + def password = project.findProperty("duct.password") ?: "Mellon" + def debug = Boolean.parseBoolean(project.findProperty("duct.debug") ?: "false") + def duration = Long.parseLong(project.findProperty("duct.duration") ?: "-1") + def count = Long.parseLong(project.findProperty("duct.count") ?: "-1") + + doLast { + def out = services.get(StyledTextOutputFactory).create("cas") + + def getCasServerNode = { + def casServerNodesArray = casServerNodes.values().toArray() + return casServerNodesArray[new SecureRandom().nextInt(casServerNodesArray.length)] as String + } + + def startTime = new Date() + def keepGoing = true + def executionCount = 0 + + while(keepGoing) { + executionCount++ + + def casServerPrefix1 = getCasServerNode() + def casServerPrefix2 = getCasServerNode() + + if (casServerNodes.size() >= 2) { + while (casServerPrefix1.equals(casServerPrefix2)) { + casServerPrefix2 = getCasServerNode() + } + } + + if (debug) { + out.withStyle(Style.Normal).println("CAS Server 1: ${casServerPrefix1}") + out.withStyle(Style.Normal).println("CAS Server 2: ${casServerPrefix2}") + out.withStyle(Style.Normal).println("Fetching ticket-granting ticket @ ${casServerPrefix1} for ${username}...") + } + def connection = new URL("${casServerPrefix1}/v1/tickets").openConnection() + connection.setRequestMethod("POST") + connection.setDoOutput(true) + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + connection.getOutputStream().write("username=${username}&password=${password}".getBytes("UTF-8")) + def rc = connection.getResponseCode() + + if (rc == 201) { + def tgt = connection.getHeaderFields().get("Location").get(0).find('TGT-.*') + + if (debug) { + out.withStyle(Style.Normal).println("Received ticket-granting ticket ${tgt} @ ${casServerPrefix2} for ${username}...") + out.withStyle(Style.Normal).println("Fetching service ticket @ ${casServerPrefix2} for ${tgt} and service ${service}...") + } + connection = new URL("${casServerPrefix2}/v1/tickets/${tgt}").openConnection() + connection.setRequestMethod("POST") + connection.setDoOutput(true) + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + connection.getOutputStream().write("service=${service}".getBytes("UTF-8")) + rc = connection.getResponseCode() + + if (rc == 200) { + def st = connection.getInputStream().getText() + if (debug) { + out.withStyle(Style.Normal).println("Received service ticket ${st} @ ${casServerPrefix1} for ${service}...") + out.withStyle(Style.Normal).println("Validating service ticket ${st} @ ${casServerPrefix1} for service ${service}...") + } + connection = new URL("${casServerPrefix1}/p3/serviceValidate?service=${service}&ticket=${st}&format=json").openConnection() + connection.setRequestMethod("GET") + connection.setDoOutput(true) + connection.setRequestProperty("Content-Type", "application/json") + rc = connection.getResponseCode() + + if (rc == 200) { + def serverResponse = connection.getInputStream().getText() + def response = new JsonSlurper().parseText(serverResponse) + + if (response.serviceResponse["authenticationSuccess"] != null) { + out.withStyle(Style.Success).println("Service ticket ${st} is successfully validated @ ${casServerPrefix1}") + } else { + out.withStyle(Style.Failure).println("Service ticket ${st} cannot be validated @ ${casServerPrefix1} for ${tgt}") + if (debug) { + out.withStyle(Style.Failure).println(serverResponse) + } + } + } else { + out.withStyle(Style.Failure).println("${rc}: Unable to validate service ticket ${st} @ ${casServerPrefix1} for ${tgt}") + } + } else { + out.withStyle(Style.Failure).println("${rc}: Unable to fetch service ticket @ ${casServerPrefix2} for ${tgt}") + } + } else { + out.withStyle(Style.Failure).println("${rc}: Unable to fetch ticket-granting ticket @ ${casServerPrefix1}") + } + + if (keepGoing && duration > 0) { + def executionDuration = TimeCategory.minus(new Date(), startTime) + keepGoing = executionDuration.getSeconds() < duration + } + if (keepGoing) { + keepGoing = executionCount < count + } + Thread.sleep(250) + } + } +} + +task exportConfigMetadata(group: "CAS", description: "Export collection of CAS properties") { + def file = new File(project.rootDir, 'config-metadata.properties') + def queryType = ConfigurationMetadataCatalogQuery.QueryTypes.CAS + if (project.hasProperty("queryType")) { + queryType = ConfigurationMetadataCatalogQuery.QueryTypes.valueOf(project.findProperty("queryType")) + } + doLast { + file.withWriter('utf-8') { writer -> + def props = CasConfigurationMetadataCatalog.query( + ConfigurationMetadataCatalogQuery.builder() + .queryType(queryType) + .build()) + .properties() + props.each { property -> + writer.writeLine("# Type: ${property.type}"); + writer.writeLine("# Module: ${property.module}") + writer.writeLine("# Owner: ${property.owner}") + if (property.deprecationLevel != null) { + writer.writeLine("# This setting is deprecated with a severity level of ${property.deprecationLevel}.") + if (property.deprecationReason != null) { + writer.writeLine("# because ${property.deprecationReason}") + } + if (property.deprecationReason != null) { + writer.writeLine("# Replace with: ${property.deprecationReason}") + } + } + writer.writeLine("#") + def description = property.description.replace("\n", "\n# ").replace("\r", "") + description = org.apache.commons.text.WordUtils.wrap(description, 70, "\n# ", true) + writer.writeLine("# ${description}") + writer.writeLine("#") + writer.writeLine("# ${property.name}: ${property.defaultValue}") + writer.writeLine("") + } + } + println "Configuration metadata is available at ${file.absolutePath}" + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..943f0cbfa Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..3499ded5c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..65dcd68d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 000000000..93e3f59f1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 000000000..3ed398bd2 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,113 @@ +## Helm Chart for CAS + +The current helm chart for cas-server demonstrates standing up CAS. +The chart functionality will grow over time, hopefully with contributions from real world deployments. +Eventually it might be nice to support a config-server. +The chart supports mapping in arbitrary volumes and cas config can be specified in values files. +The config could be in cloud config rather than kubernetes config maps, the service registry +could be in a database, git, or a simple json registry in a kubernetes persistent volume. The ticket registry could use a standard helm chart for redis, +postgresql, or mongo, etc. +Currently the chart is attempting to use SSL between ingress controller and the CAS servers. +This is probably overkill and involves all the pain that comes with SSL (e.g. trust & hostname verification). +This chart uses stateful set for CAS rather than a deployment and this may change in the future. + +#### Warning: semver versioning will not be employed until published to a repository. + +### Install Kubernetes (Docker for Windows/Mac, Minikube, K3S, Rancher, etc) + + - [Docker Desktop](https://www.docker.com/products/docker-desktop) + + - [Minikube](https://minikube.sigs.k8s.io/docs/start/) + + - [k3s](https://k3s.io/) - Works on linux, very light-weight and easy to install for development + ```shell script + curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --disable traefik" sh + # the following export is for helm + export KUBECONFIG=/etc/rancher/k3s/k3s.yaml + ./gradlew clean build jibBuildTar --refresh-dependencies + k3s ctr images import build/jib-image.tar + k3s ctr images ls | grep cas + ./gradlew createKeystore + cd helm + # create secret for tomcat + kubectl create secret generic cas-server-keystore --from-file=thekeystore=/etc/cas/thekeystore + # create secret for ingress controller to use with CAS ingress (nginx-ingress will use default if you don't create) + ./create-ingress-tls.sh + # install cas-server helm chart + helm upgrade --install cas-server ./cas-server + ``` + +### Install Helm and Kubectl + +Helm v3 and Kubectl are just single binary programs. Kubectl may come with your kubernetes +installation, but you can download both of programs and put them in your path. + - Install [Helm](https://helm.sh/docs/intro/install/) + - Install [Kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl/) + +### Install ingress controller + +CAS helm chart only tested with Kubernetes ingress-nginx, feel free to add support for other ingress controllers. + +[Kubernetes Nginx Ingress Installation Guide](https://kubernetes.github.io/ingress-nginx/deploy/) + +### Create secret containing keystore + +Assuming you have run `./gradlew createKeystore` or put you server keystore in `/etc/cas/thekeystore`, +run the following to create a secret containing the keystore: +```shell script +kubectl create secret generic cas-server-keystore --from-file=thekeystore=/etc/cas/thekeystore +``` + +### Install CAS Server helm chart + +Helm charts consist of templates which are combined with values from one or more values files +(and command line set arguments) to produce kubernetes yaml. The templates folder contains a default +values.yaml that is used by default but additional values files can be specified on the command line. +The following examples use the `default` namespace but `--namespace cas` can be added to any resources +created by the helm command to use the specified kubernetes namespace. +``` +# delete cas-server helm chart install +helm delete cas-server +# install cas-server chart +helm install cas-server ./cas-server +# install or update cas-server +helm upgrade --install cas-server ./cas-server +# use local values file to override defaults +helm upgrade --install cas-server --values values-local.yaml ./cas-server +# see kubernetes yaml without installing +helm upgrade --install cas-server --values values-local.yaml ./cas-server --dry-run --debug +# sometimes dry-run fails b/c yaml can't convert to json so use template instead to see problem +helm template cas-server --values values-local.yaml ./cas-server --debug +``` + +### Useful `kubectl` Commands + +``` +# tail the console logs +kubectl logs cas-server-0 -f +# exec into container +kubectl exec -it cas-server-0 sh +# bounce CAS pod +kubectl delete pod cas-server-0 +``` + +### Browse to CAS + +Make sure you have host entries for whatever host is listed in values file for this entry: +``` +ingress: + hosts: + - host: cas.example.org + paths: + - "/cas" + tls: + - secretName: cas-server-ingress-tls + hosts: + - cas.example.org +``` + +``` +# host entry +127.0.0.1 cas.example.org +``` +Browse to `https://cas.example.org/cas/login` diff --git a/helm/cas-server/.helmignore b/helm/cas-server/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/helm/cas-server/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm/cas-server/Chart.yaml b/helm/cas-server/Chart.yaml new file mode 100644 index 000000000..c999c35ee --- /dev/null +++ b/helm/cas-server/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: cas-server +description: A Helm chart for CAS SSO Server +icon: "https://apereo.github.io/cas/images/cas_logo.png" + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +appVersion: 'latest' diff --git a/helm/cas-server/templates/NOTES.txt b/helm/cas-server/templates/NOTES.txt new file mode 100644 index 000000000..37db28b75 --- /dev/null +++ b/helm/cas-server/templates/NOTES.txt @@ -0,0 +1,23 @@ +1. Get the application URL by running these commands: +{{- if .Values.cas.ingress.enabled }} +{{- range $host := .Values.cas.ingress.hosts }} + {{- range .paths }} + curl -k -v http{{ if $.Values.cas.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}/login + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "cas-server.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "cas-server.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "cas-server.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "cas-server.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 +{{- end }} + +Kubernetes Version: {{ .Capabilities.KubeVersion.Version }} diff --git a/helm/cas-server/templates/_helpers.tpl b/helm/cas-server/templates/_helpers.tpl new file mode 100644 index 000000000..06e78af90 --- /dev/null +++ b/helm/cas-server/templates/_helpers.tpl @@ -0,0 +1,128 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "cas-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "cas-server.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "cas-server.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "cas-server.labels" -}} +helm.sh/chart: {{ include "cas-server.chart" . }} +{{ include "cas-server.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "cas-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cas-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "cas-server.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "cas-server.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Return the proper cas-server image name +*/}} +{{- define "cas-server.imageName" -}} +{{ include "common.images.image" (dict "imageRoot" .Values.image "global" .Values.global) }} +{{- end -}} + + +{{/* +Return the proper image name (for the init container volume-permissions image) +*/}} +{{- define "cas-server.volumePermissions.image" -}} +{{ include "common.images.image" (dict "imageRoot" .Values.volumePermissions.image "global" .Values.global) }} +{{- end -}} + +{{/* +Return the proper image name +{{ include "common.images.image" ( dict "imageRoot" .Values.path.to.the.image "global" $) }} +*/}} +{{- define "common.images.image" -}} +{{- $registryName := .imageRoot.registry -}} +{{- $repositoryName := .imageRoot.repository -}} +{{- $tag := default "latest" .imageRoot.tag | toString -}} +{{- if .global }} + {{- if .global.imageRegistry }} + {{- $registryName = .global.imageRegistry -}} + {{- end -}} +{{- end -}} +{{- if ne $registryName "" }} + {{- printf "%s/%s:%s" $registryName $repositoryName $tag -}} +{{- else -}} + {{- printf "%s:%s" $repositoryName $tag -}} +{{- end -}} +{{- end -}} + + +{{/* +Return log directory volume +*/}} +{{- define "cas-server.logdir" -}} +{{- if .Values.logdir.hostPath -}} +hostPath: + path: {{ .Values.logdir.hostPath }} + type: Directory +{{- else if .Values.logdir.claimName -}} +persistentVolumeClaim: + claimName: {{ .Values.logdir.claimName }} +{{- else -}} +emptyDir: {} +{{- end }} +{{- end -}} + + +{{/* +Renders a value that contains template. +Usage: +{{ include "cas-server.tplvalues.render" ( dict "value" .Values.path.to.the.Value "context" $) }} +*/}} +{{- define "cas-server.tplvalues.render" -}} + {{- if typeIs "string" .value }} + {{- tpl .value .context }} + {{- else }} + {{- tpl (.value | toYaml) .context }} + {{- end }} +{{- end -}} diff --git a/helm/cas-server/templates/casconfig-configmap.yaml b/helm/cas-server/templates/casconfig-configmap.yaml new file mode 100644 index 000000000..3f121681a --- /dev/null +++ b/helm/cas-server/templates/casconfig-configmap.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cas-server.fullname" . }}-casconfig + labels: {{- include "cas-server.labels" . | nindent 4 }} +data: + {{- include "cas-server.tplvalues.render" (dict "value" .Values.casServerContainer.casConfig "context" $) | nindent 2 }} diff --git a/helm/cas-server/templates/ingress.yaml b/helm/cas-server/templates/ingress.yaml new file mode 100644 index 000000000..b17da9e89 --- /dev/null +++ b/helm/cas-server/templates/ingress.yaml @@ -0,0 +1,53 @@ +{{- if .Values.cas.ingress.enabled -}} +{{- $fullName := include "cas-server.fullname" . -}} +{{- $svcPort := .Values.cas.service.port -}} +{{- $kubeVersion := .Capabilities.KubeVersion.Version -}} +{{- if semverCompare ">=1.19.0" $kubeVersion }} +apiVersion: networking.k8s.io/v1 +{{- else -}} +apiVersion: networking.k8s.io/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "cas-server.labels" . | nindent 4 }} + {{- with .Values.cas.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.cas.ingress.tls }} + tls: + {{- range .Values.cas.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.cas.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ . }} + {{- if semverCompare ">=1.18.0" $kubeVersion }} + pathType: Prefix + {{- end }} + {{- if semverCompare ">=1.19.0" $kubeVersion }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + backend: + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} diff --git a/helm/cas-server/templates/role.yaml b/helm/cas-server/templates/role.yaml new file mode 100644 index 000000000..352265629 --- /dev/null +++ b/helm/cas-server/templates/role.yaml @@ -0,0 +1,12 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "cas-server.fullname" . }} + labels: + {{- include "cas-server.labels" . | nindent 4 }} +rules: +- apiGroups: ["", "extensions", "apps"] + resources: ["configmaps", "pods", "services", "endpoints", "secrets"] + verbs: ["get", "list", "watch"] +{{- end -}} diff --git a/helm/cas-server/templates/rolebinding.yaml b/helm/cas-server/templates/rolebinding.yaml new file mode 100644 index 000000000..efbd54600 --- /dev/null +++ b/helm/cas-server/templates/rolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "cas-server.fullname" . }} + labels: + {{- include "cas-server.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "cas-server.fullname" . }} +subjects: +- kind: ServiceAccount + name: {{ template "cas-server.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{ end }} diff --git a/helm/cas-server/templates/script-configmap.yaml b/helm/cas-server/templates/script-configmap.yaml new file mode 100644 index 000000000..0aee4ffae --- /dev/null +++ b/helm/cas-server/templates/script-configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "cas-server.fullname" . }}-scripts + labels: {{- include "cas-server.labels" . | nindent 4 }} +data: + entrypoint.sh: |- + #!/bin/sh + echo Working Directory: $(pwd) + # Set debug options if required + JAVA_DEBUG_ARGS= + if [ "${JAVA_ENABLE_DEBUG}" == "true" ]; then + JAVA_DEBUG_ARGS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=${JAVA_DEBUG_SUSPEND:-n},address=${JAVA_DEBUG_PORT:-5005}" + echo "Run the following to forward local port to pod:" + echo "kubectl port-forward $HOSTNAME ${JAVA_DEBUG_PORT:-5005}:${JAVA_DEBUG_PORT:-5005}" + fi + PROFILE_OPT= + if [ ! -z $CAS_SPRING_PROFILES ]; then + PROFILE_OPT="--spring.profiles.active=$CAS_SPRING_PROFILES" + fi + echo java -server -noverify $JAVA_DEBUG_ARGS $MAX_HEAP_OPT $NEW_HEAP_OPT $JVM_EXTRA_OPTS -jar $CAS_WAR $PROFILE_OPT $@ + exec java -server -noverify $JAVA_DEBUG_ARGS $MAX_HEAP_OPT $NEW_HEAP_OPT $JVM_EXTRA_OPTS -jar $CAS_WAR $PROFILE_OPT $@ diff --git a/helm/cas-server/templates/service.yaml b/helm/cas-server/templates/service.yaml new file mode 100644 index 000000000..a9e9875db --- /dev/null +++ b/helm/cas-server/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "cas-server.fullname" . }} + labels: + {{- include "cas-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.cas.service.type }} + publishNotReadyAddresses: {{ .Values.cas.service.publishNotReadyAddresses }} + ports: + - port: {{ .Values.cas.service.port }} + targetPort: https + protocol: TCP + name: https + selector: + {{- include "cas-server.selectorLabels" . | nindent 4 }} diff --git a/helm/cas-server/templates/serviceaccount.yaml b/helm/cas-server/templates/serviceaccount.yaml new file mode 100644 index 000000000..3fef81088 --- /dev/null +++ b/helm/cas-server/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "cas-server.serviceAccountName" . }} + labels: + {{- include "cas-server.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/cas-server/templates/statefulset.yaml b/helm/cas-server/templates/statefulset.yaml new file mode 100644 index 000000000..595449f60 --- /dev/null +++ b/helm/cas-server/templates/statefulset.yaml @@ -0,0 +1,263 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "cas-server.fullname" . }} + labels: {{- include "cas-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + updateStrategy: + type: {{ .Values.updateStrategy | quote}} + serviceName: {{ include "cas-server.fullname" . }} + podManagementPolicy: {{ .Values.podManagementPolicy | quote}} + selector: + matchLabels: + {{- include "cas-server.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{ if .Values.casServerContainer.alwaysRoll }} + rollme: {{ randAlphaNum 5 | quote }} + {{- else }} + rollme: "rolldisabled" + {{- end }} + labels: + {{- include "cas-server.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "cas-server.serviceAccountName" . }} + {{- if .Values.podSecurityContext.enabled }} + securityContext: {{- omit .Values.podSecurityContext "enabled" | toYaml | nindent 8 }} + {{- end }} + volumes: + {{- range $.Values.casServerContainer.casConfigMounts }} + {{- $configMount := printf "%s-%s" "cas-config" . | replace "." "-" | replace "_" "-" | lower }} + - name: {{ $configMount | quote }} + configMap: + name: {{ include "cas-server.fullname" $ }}-casconfig + defaultMode: 0644 + {{- end }} + - name: scripts + configMap: + name: {{ include "cas-server.fullname" . }}-scripts + defaultMode: 0755 + - name: logdir + {{- include "cas-server.logdir" . | nindent 10 }} + {{- if .Values.casServerContainer.serverKeystoreExistingSecret }} + - name: cas-server-keystore + secret: + secretName: {{ .Values.casServerContainer.serverKeystoreExistingSecret }} + defaultMode: 0444 + items: + - key: {{ .Values.casServerContainer.serverKeystoreSubPath }} + path: {{ .Values.casServerContainer.serverKeystoreSubPath }} + {{- end }} + {{- if .Values.casServerContainer.extraVolumes }} + {{- include "cas-server.tplvalues.render" ( dict "value" .Values.casServerContainer.extraVolumes "context" $ ) | nindent 8 }} + {{- end }} + {{- if or .Values.casServerContainer.initContainers (and .Values.podSecurityContext.enabled .Values.volumePermissions.enabled .Values.persistence.enabled) }} + initContainers: + {{- if and .Values.podSecurityContext.enabled .Values.volumePermissions.enabled .Values.persistence.enabled }} + - name: volume-permissions + image: {{ include "cas-server.volumePermissions.image" . }} + imagePullPolicy: {{ .Values.volumePermissions.image.pullPolicy | quote }} + command: + - /bin/sh + - -cx + - | + {{- if .Values.persistence.enabled }} + {{- if eq ( toString ( .Values.volumePermissions.securityContext.runAsUser )) "auto" }} + chown `id -u`:`id -G | cut -d " " -f2` {{ .Values.persistence.mountPath }} + {{- else }} + chown {{ .Values.containerSecurityContext.runAsUser }}:{{ .Values.podSecurityContext.fsGroup }} {{ .Values.persistence.mountPath }} + {{- end }} + mkdir -p {{ .Values.persistence.mountPath }}/data + chmod 700 {{ .Values.persistence.mountPath }}/data + find {{ .Values.persistence.mountPath }} -mindepth 1 -maxdepth 1 -not -name ".snapshot" -not -name "lost+found" | \ + {{- if eq ( toString ( .Values.volumePermissions.securityContext.runAsUser )) "auto" }} + xargs chown -R `id -u`:`id -G | cut -d " " -f2` + {{- else }} + xargs chown -R {{ .Values.containerSecurityContext.runAsUser }}:{{ .Values.podSecurityContext.fsGroup }} + {{- end }} + {{- end }} + {{- if eq ( toString ( .Values.volumePermissions.securityContext.runAsUser )) "auto" }} + securityContext: {{- omit .Values.volumePermissions.securityContext "runAsUser" | toYaml | nindent 12 }} + {{- else }} + securityContext: {{- .Values.volumePermissions.securityContext | toYaml | nindent 12 }} + {{- end }} + {{- if .Values.volumePermissions.resources }} + resources: {{- toYaml .Values.volumePermissions.resources | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- end }} + {{- if .Values.casServerContainer.initContainers }} + {{- include "cas-server.tplvalues.render" (dict "value" .Values.casServerContainer.initContainers "context" $) | nindent 8 }} + {{- end }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + {{- if .Values.containerSecurityContext.enabled }} + securityContext: {{- omit .Values.containerSecurityContext "enabled" | toYaml | nindent 12 }} + {{- end }} + image: {{ include "cas-server.imageName" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + {{- if .Values.casServerContainer.warPath }} + - name: CAS_WAR + value: {{ .Values.casServerContainer.warPath | quote }} + {{- end }} + {{- if .Values.casServerContainer.profiles }} + - name: CAS_SPRING_PROFILES + value: {{ .Values.casServerContainer.profiles | quote }} + {{- end }} + {{- if .Values.casServerContainer.jvm.maxHeapOpt }} + - name: MAX_HEAP_OPT + value: {{ .Values.casServerContainer.jvm.maxHeapOpt | quote }} + {{- end }} + {{- if .Values.casServerContainer.jvm.minHeapOpt }} + - name: MIN_HEAP_OPT + value: {{ .Values.casServerContainer.jvm.minHeapOpt | quote }} + {{- end }} + {{- if .Values.casServerContainer.jvm.extraOpts }} + - name: JVM_EXTRA_OPTS + value: {{ .Values.casServerContainer.jvm.extraOpts | quote }} + {{- end }} + - name: JAVA_ENABLE_DEBUG + value: {{ .Values.casServerContainer.jvm.debugEnabled | quote }} + - name: JAVA_DEBUG_SUSPEND + value: {{ .Values.casServerContainer.jvm.debugSuspend | quote }} + - name: 'KUBERNETES_NAMESPACE' # used by org.apache.catalina.tribes.membership.cloud.CloudMembershipProvider + value: {{ .Release.Namespace }} + - name: 'POD_IP' + valueFrom: + fieldRef: + fieldPath: status.podIP + {{- if .Values.casServerContainer.extraEnvVars }} + {{- include "cas-server.tplvalues.render" (dict "value" .Values.casServerContainer.extraEnvVars "context" $) | nindent 12 }} + {{- end }} + envFrom: + {{- if .Values.casServerContainer.extraEnvVarsConfigMap }} + - configMapRef: + name: {{ .Values.casServerContainer.extraEnvVarsConfigMap }} + {{- end }} + {{- if .Values.casServerContainer.extraEnvVarsSecret }} + - secretRef: + name: {{ .Values.casServerContainer.extraEnvVarsSecret }} + {{- end }} + {{- if .Values.casServerContainer.command }} + command: {{- include "cas-server.tplvalues.render" (dict "value" .Values.casServerContainer.command "context" $) | nindent 12 }} + {{- else }} + command: + - '/entrypoint.sh' + {{- end }} + {{- if .Values.casServerContainer.args }} + args: {{- include "cas-server.tplvalues.render" (dict "value" .Values.casServerContainer.args "context" $) | nindent 12 }} + {{- end }} + ports: + - name: https + containerPort: {{ .Values.cas.listenPortHttps }} + protocol: TCP + - name: jvm-debug + containerPort: {{ .Values.cas.listenPortJvmDebug }} + protocol: TCP + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.persistence.mountPath }} + {{- end }} + {{- range $.Values.casServerContainer.casConfigMounts }} + {{- $configMount := printf "%s-%s" "cas-config" . | replace "." "-" | replace "_" "-" | lower }} + {{- $configMountPath := printf "%s/%s" "/etc/cas/config" . }} + - name: {{ $configMount | quote }} + mountPath: {{ $configMountPath }} + subPath: {{ . | quote }} + {{- end }} + - name: scripts + mountPath: /entrypoint.sh + subPath: entrypoint.sh + - name: logdir + mountPath: {{ .Values.logdir.mountPath }} + {{- if .Values.casServerContainer.serverKeystoreExistingSecret }} + - name: cas-server-keystore + mountPath: {{ .Values.casServerContainer.serverKeystoreMountPath }} + subPath: {{ .Values.casServerContainer.serverKeystoreSubPath }} + {{- end }} + {{- if .Values.casServerContainer.extraVolumeMounts }} + {{- include "cas-server.tplvalues.render" ( dict "value" .Values.casServerContainer.extraVolumeMounts "context" $ ) | nindent 12 }} + {{- end }} + startupProbe: + httpGet: + path: {{ .Values.casServerContainer.defaultStatusUrl }} + port: https + scheme: HTTPS + {{- if .Values.casServerContainer.defaultStatusHeaders }} + {{- include "cas-server.tplvalues.render" ( dict "value" .Values.casServerContainer.defaultStatusHeaders "context" $ ) | nindent 14 }} + {{- end }} + failureThreshold: {{ .Values.casServerContainer.startupFailureThreshold }} + periodSeconds: 20 + readinessProbe: + httpGet: + path: {{ .Values.casServerContainer.defaultStatusUrl }} + port: https + scheme: HTTPS + {{- if .Values.casServerContainer.defaultStatusHeaders }} + {{- include "cas-server.tplvalues.render" ( dict "value" .Values.casServerContainer.defaultStatusHeaders "context" $ ) | nindent 14 }} + {{- end }} + initialDelaySeconds: {{ .Values.casServerContainer.readinessInitialDelaySeconds }} + periodSeconds: 5 + failureThreshold: {{ .Values.casServerContainer.readinessFailureThreshold }} + livenessProbe: + httpGet: + path: {{ .Values.casServerContainer.defaultStatusUrl }} + port: https + scheme: HTTPS + {{- if .Values.casServerContainer.defaultStatusHeaders }} + {{- include "cas-server.tplvalues.render" ( dict "value" .Values.casServerContainer.defaultStatusHeaders "context" $ ) | nindent 14 }} + {{- end }} + initialDelaySeconds: {{ .Values.casServerContainer.livenessInitialDelaySeconds }} + periodSeconds: 15 + failureThreshold: {{ .Values.casServerContainer.livenessFailureThreshold }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: data + {{- with .Values.persistence.annotations }} + annotations: + {{- range $key, $value := . }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + spec: + accessModes: + {{- range .Values.persistence.accessModes }} + - {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} + storageClassName: {{ .Values.persistence.storageClassName | quote }} + {{- if .Values.persistence.selector }} + selector: {{- include "cas-server.tplvalues.render" (dict "value" .Values.persistence.selector "context" $) | nindent 10 }} + {{- end -}} +{{- end }} \ No newline at end of file diff --git a/helm/cas-server/templates/tests/test-cas-server.yaml b/helm/cas-server/templates/tests/test-cas-server.yaml new file mode 100644 index 000000000..2ca0f1403 --- /dev/null +++ b/helm/cas-server/templates/tests/test-cas-server.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "cas-server.fullname" . }}-test" + labels: + {{- include "cas-server.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test-success +spec: + containers: + - name: wget + image: alpine + command: ['wget'] + args: [ '--no-check-certificate', 'https://{{ include "cas-server.fullname" . }}:{{ .Values.cas.service.port }}{{ .Values.casServerContainer.defaultStatusUrl }}' ] + restartPolicy: Never + diff --git a/helm/cas-server/values.yaml b/helm/cas-server/values.yaml new file mode 100644 index 000000000..b9bced1f9 --- /dev/null +++ b/helm/cas-server/values.yaml @@ -0,0 +1,371 @@ +# Default values for cas-server. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +casServerName: cas.example.org + +replicaCount: 1 + +image: + registry: "" + repository: "apereo/cas" + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# There are two valid stateful set update strategies, RollingUpdate and the (legacy) OnDelete +updateStrategy: RollingUpdate + +# OrderedReady: Pods are created in increasing order (pod-0, then pod-1, etc) and the controller will wait until each pod is ready before continuing. +# When scaling down, the pods are removed in the opposite order. +# Parallel: Creates pods in parallel to match the desired scale without waiting, and on scale down will delete all pods at once. +podManagementPolicy: OrderedReady + +# Map folder for logs directory from host or pvc, or leave both blank to use emptyDir volume +# In docker for windows hostPath could be '/host_mnt/c/opt/cas/logs' +# Windows: Give full access local Users group to the to ~/.docker folder if getting permission denied) +logdir: +# hostPath: '/host_mnt/c/opt/cas/logs' + hostPath: '' + claimName: '' + mountPath: '/var/log' + +# CAS Server container properties +casServerContainer: + ## Roll on upgrade changes deployment when helm upgrade runs, forcing pod to restart + alwaysRoll: false + ## JVM Settings + ## JVM settings only used if command not set, use args to set app arguments + jvm: + ## Extra JVM options + ## + extraOpts: '-Djavax.net.ssl.trustStore=/etc/cas/truststore -Djavax.net.ssl.trustStoreType=PKCS12 -Djavax.net.ssl.trustStorePassword=changeit' + + ## Memory settings: If these aren't defined, java will calc values automatically, but requires setting limits on pod + ## so it doesn't base heap size on host memory + maxHeapOpt: '-Xmx2G' + newHeapOpt: '-Xms600M' + debugEnabled: true + debugSuspend: "n" # could be n or y, must quote or yaml changes to boolean + warPath: 'cas.war' + ## Override cmd + ## + command: + ## Override args + ## + args: + ## extraVolumes and extraVolumeMounts allows you to mount other volumes + ## Examples: + ## extraVolumeMounts: + ## - name: extras + ## mountPath: /usr/share/extras + ## readOnly: true + ## extraVolumes: + ## - name: extras + ## emptyDir: {} + ## + profiles: 'standalone' + + extraVolumeMounts: + - name: truststore + mountPath: /etc/cas/truststore + subPath: truststore + + extraVolumes: + - name: truststore + configMap: + name: cas-truststore + defaultMode: 0444 + + ## Url to use for readiness, startupprobe, and liveliness check, change to health actuator if the module is available + ## Naming it "default" in case in future template supports individual urls for the different checks, with this as default if they aren't specified + defaultStatusUrl: '/cas/actuator/health' + + # number of startup probe failures before it will be killed, set high if trying to debug startup issues + # liveness and readiness failure threshold might be 1 but startup failure threshold accounts for + # failures while server is starting up + startupFailureThreshold: 30 + livenessFailureThreshold: 1 + readinessFailureThreshold: 1 + readinessInitialDelaySeconds: 45 + livenessInitialDelaySeconds: 120 + + ## Extra init containers to add to the statefulset + ## + initContainers: [] + + ## An array to add extra env vars + ## For example: + ## extraEnvVars: + ## - name: MY_ENV_VAR + ## value: env_var_value + ## + extraEnvVars: [] + + ## Name of a ConfigMap containing extra env vars + ## + extraEnvVarsConfigMap: '' + + # name of secret containing server keystore + serverKeystoreExistingSecret: cas-server-keystore + # folder that should container the keystore + serverKeystoreMountPath: '/etc/cas/thekeystore' + # name of keystore file in container and in secret + serverKeystoreSubPath: 'thekeystore' + + ## Name of a Secret containing extra env vars + ## + extraEnvVarsSecret: '' + ## Choose which config files from casConfig to mount + casConfigMounts: + - 'cas.properties' + - 'cas.yaml' + ## Create various config files from casConfig that may or may not be mounted + casConfig: + # issue with line breaks? means can't use {{}} variables after first line + # workaround is to use {{}} variables in yaml version of properties file + cas.properties: |- + cas.server.name=https://{{ .Values.casServerName }} + context.path=/cas + cas.server.prefix=${cas.server.name}${context.path} + + cas.http-client.truststore.psw=changeit + cas.http-client.truststore.file=/etc/cas/truststore + + # put web access logs in same directory as cas logs + cas.server.tomcat.ext-access-log.directory=/var/log + cas.server.tomcat.ext-access-log.enabled=true + + # uncomment the folowing to not allow login of built-in users + # cas.authn.accept.users= + + # since we are behind ingress controller, need to use x-forwarded-for to get client ip + # if nginx ingress controller is behind another proxy, it needs to be configured globally with the following settings in the ingress controller configmap + # use-forwarded-headers: "true" # very important for CAS or any app that compares IP being used against IP that initiated sessions (session fixation) + # enable-underscores-in-headers: "true" # while you are at it, allow underscores in headers, can't recall if important for cas but no need to have nginx dropping your headers with underscores + cas.audit.engine.alternate-client-addr-header-name=X-Forwarded-For + server.tomcat.remoteip.remote-ip-header=X-FORWARDED-FOR + + server.ssl.key-store=file:/etc/cas/thekeystore + server.ssl.key-store-type=PKCS12 + server.ssl.key-store-password=changeit + server.ssl.trust-store=file:/etc/cas/truststore + server.ssl.trust-store-type=PKCS12 + server.ssl.trust-store-password=changeit + + # expose endpoints via http + management.endpoints.web.exposure.include=health,info,prometheus,metrics,env,loggers,statistics,status,loggingConfig,events,configurationMetadata,caches + management.endpoints.web.base-path=/actuator + management.endpoints.web.cors.allowed-origins=https://${cas-host} + management.endpoints.web.cors.allowed-methods=GET,POST + + # enable endpoints + management.endpoint.metrics.enabled=true + management.endpoint.health.enabled=true + management.endpoint.info.enabled=true + management.endpoint.env.enabled=true + management.endpoint.loggers.enabled=true + management.endpoint.status.enabled=true + management.endpoint.statistics.enabled=true + management.endpoint.prometheus.enabled=true + management.endpoint.events.enabled=true + management.endpoint.loggingConfig.enabled=true + management.endpoint.configurationMetadata.enabled=true + # configure health endpoint + management.health.defaults.enabled=false + management.health.ping.enabled=true + management.health.caches.enabled=true + + # secure endpoints to localhost + + cas.monitor.endpoints.endpoint.defaults.access[0]=AUTHENTICATED + cas.monitor.endpoints.endpoint.health.access[0]=IP_ADDRESS + cas.monitor.endpoints.endpoint.health.requiredIpAddresses[0]=127.0.0.1 + cas.monitor.endpoints.endpoint.health.requiredIpAddresses[1]=0:0:0:0:0:0:0:1 + cas.monitor.endpoints.endpoint.health.requiredIpAddresses[2]=10\\..* + cas.monitor.endpoints.endpoint.health.requiredIpAddresses[3]=172\\.16\\..* + cas.monitor.endpoints.endpoint.health.requiredIpAddresses[4]=192\\.168\\..* + #eof + + cas.yaml: |- + --- + logging: + config: 'file:/etc/cas/config/log4j2.xml' + cas: + server: + tomcat: + clustering: + enabled: true + clustering-type: 'CLOUD' + cloud-membership-provider: 'kubernetes' + spring: + security: + user: + name: "{{ .Values.casAdminUser }}" + password: "{{ .Values.casAdminPassword }}" + #eof + +podAnnotations: {} + +## Pod security context +## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-the-security-context-for-a-pod +## +podSecurityContext: + enabled: true + fsGroup: 1000 + +containerSecurityContext: + enabled: false + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + runAsUser: 1000 + +## Override parts of this ingress in your own values file with appropriate host names +## This currently is only set up to work with Nginx Ingress Controller from Kubernetes project +cas: + service: + type: ClusterIP + publishNotReadyAddresses: true + port: 8443 + listenPortHttps: 8443 + listenPortJvmDebug: 5005 + ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/session-cookie-samesite: "None" + nginx.ingress.kubernetes.io/session-cookie-conditional-samesite-none: "true" + nginx.ingress.kubernetes.io/affinity: "cookie" + nginx.ingress.kubernetes.io/session-cookie-name: "sticky-session-route" + nginx.ingress.kubernetes.io/session-cookie-hash: "sha1" + nginx.ingress.kubernetes.io/secure-backends: "true" + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + hosts: + - host: cas.example.org + paths: + - "/cas" + - host: kubernetes.docker.internal + paths: + - "/cas" + tls: + - secretName: cas-server-ingress-tls + hosts: + - cas.example.org + - kubernetes.docker.internal + +# Request some resources for main cas server so kubernetes will schedule somewhere with enough resources +# Limits can also be set if desired +resources: + requests: + cpu: 100m + memory: 512Mi +# limits: +# cpu: 100m +# memory: 128Mi + +# node selector for CAS server +nodeSelector: {} +# tolerations for CAS server (i.e taints on nodes that it can tolerate) +tolerations: [] +# affinity config for CAS server +affinity: {} + +casAdminUser: 'casuser' +casAdminPassword: 'Mellon' + +# rbac may or may not be necessary, but it can allow for certain types of discovery (e.g. tomcat cloud session replication) +rbac: + # specified whether RBAC resources should be created + create: true + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + +## CAS can use a persistent volume to store config such as services and saml IDP/SP metadata that it pulls from git +## Enable persistence using Persistent Volume Claims +## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/ +## +persistence: + ## If true, use a Persistent Volume Claim for data folder mounted where you specify using mountPath + ## + enabled: true + ## Persistent Volume Storage Class + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. (gp2 on AWS, standard on + ## GKE, AWS & OpenStack) + ## + # storageClass: "-" + ## Persistent Volume Claim annotations + ## + annotations: + ## Persistent Volume Access Mode + ## + accessModes: + - ReadWriteOnce + ## Persistent Volume size + ## + size: 2Gi + ## The path the volume will be mounted at, will contain writable folder called "data" under mountPath, + ## if volumePermissions init container creates it + ## + mountPath: /var/cas + +## Init containers parameters: +## volumePermissions: Change the owner and group of the persistent volume mountpoint to runAsUser:fsGroup values from +## the securityContext section. +## +volumePermissions: + enabled: false + image: + registry: docker.io + repository: alpine + tag: latest + pullPolicy: Always + ## Optionally specify an array of imagePullSecrets. + ## Secrets must be manually created in the namespace. + ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + ## + # pullSecrets: + # - myRegistryKeySecretName + ## Init container' resource requests and limits + ## ref: http://kubernetes.io/docs/user-guide/compute-resources/ + ## + resources: + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + limits: {} + # cpu: 100m + # memory: 128Mi + requests: {} + # cpu: 100m + # memory: 128Mi + ## Init container Security Context + ## Note: the chown of the data folder is done to securityContext.runAsUser + ## and not the below volumePermissions.securityContext.runAsUser + ## When runAsUser is set to special value "auto", init container will try to chown the + ## data folder to autodetermined user&group, using commands: `id -u`:`id -G | cut -d" " -f2` + ## "auto" is especially useful for OpenShift which has scc with dynamic userids (and 0 is not allowed). + ## You may want to use this volumePermissions.securityContext.runAsUser="auto" in combination with + ## pod securityContext.enabled=false and shmVolume.chmod.enabled=false + ## + securityContext: + runAsUser: 0 diff --git a/helm/create-cas-server-keystore-secret.sh b/helm/create-cas-server-keystore-secret.sh new file mode 100755 index 000000000..68dd3d4a5 --- /dev/null +++ b/helm/create-cas-server-keystore-secret.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# This script needs bash for pushd/popd +set -e +NAMESPACE=${1:-default} +KEYSTORE=../etc/cas/thekeystore + +# it's important that the service names are supported in the cert used for tomcat in cas-server +# keytool doesn't support wildcards which we really need to use here, e.g. *.cas-server.${NAMESPACE}.svc +# java wasn't resolving using all available dns suffixes so had to use [namespace].svc +SUBJECT=CN=cas.example.org,OU=Example,OU=Org,C=US +SAN=dns:cas.example.org,dns:casadmin.example.org,dns:cas-server-0.cas-server.${NAMESPACE}.svc,dns:cas-server-1.cas-server.${NAMESPACE}.svc + +if [ ! -f "$KEYSTORE" ] ; then + pushd .. + ./gradlew --no-configuration-cache createKeyStore -PcertDir=./etc/cas -PcertificateDn="${SUBJECT}" -PcertificateSubAltName="${SAN}" + popd +fi + +kubectl delete secret cas-server-keystore --namespace "${NAMESPACE}" || true +kubectl create secret generic cas-server-keystore --namespace "${NAMESPACE}" --from-file=thekeystore=$KEYSTORE diff --git a/helm/create-ingress-tls.sh b/helm/create-ingress-tls.sh new file mode 100755 index 000000000..f2c5c7fad --- /dev/null +++ b/helm/create-ingress-tls.sh @@ -0,0 +1,19 @@ +#!/bin/sh +NAMESPACE=${1:-default} +SUBJECT=/CN=cas.example.org/OU=Auth/O=example +SAN=DNS:casadmin.example.org,DNS:cas.example.org +SECRET_NAME=cas-server-ingress-tls +KEY_FILE=cas-ingress.key +CERT_FILE=cas-ingress.crt + +set -e + +# create certificate for external ingress +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "${KEY_FILE}" -out ${CERT_FILE} -subj "${SUBJECT}" \ + -addext "subjectAltName = $SAN" + +kubectl delete secret "${SECRET_NAME}" --namespace "${NAMESPACE}" || true +# create tls secret with key and cert +kubectl create secret tls "${SECRET_NAME}" --namespace "${NAMESPACE}" --key "${KEY_FILE}" --cert "${CERT_FILE}" + diff --git a/helm/create-truststore.sh b/helm/create-truststore.sh new file mode 100755 index 000000000..ebd01cf55 --- /dev/null +++ b/helm/create-truststore.sh @@ -0,0 +1,42 @@ +#!/bin/sh +NAMESPACE=${1:-default} +INGRESS_CERT_FILE=cas-ingress.crt +CAS_CERT_FILE=cas.crt +CAS_KEYSTORE=../etc/cas/thekeystore +TRUST_STORE=../etc/cas/truststore +JAVA_CACERTS=${2:-/etc/ssl/certs/java/cacerts} + +STORE_PASS=changeit + +set -e + +if [ -f ${TRUST_STORE} ]; then + rm ${TRUST_STORE} +fi + +if [ -f "${JAVA_CACERTS}" ]; then + keytool -importkeystore -noprompt -srckeystore "${JAVA_CACERTS}" -srcstorepass "${STORE_PASS}" -destkeystore "${TRUST_STORE}" -deststoretype PKCS12 -deststorepass "${STORE_PASS}" +else + echo "Missing ${JAVA_CACERTS} JAVA_HOME is ${JAVA_HOME}" + if [ -d "${JAVA_HOME}" ]; then + find ${JAVA_HOME} -name cacerts -print + find ${JAVA_HOME} -name cacerts -exec keytool -importkeystore -noprompt -srckeystore {} -srcstorepass "${STORE_PASS}" -destkeystore "${TRUST_STORE}" -deststoretype PKCS12 -deststorepass "${STORE_PASS}" \; + fi +fi + +# create truststore that trusts ingress cert +if [ -f "${INGRESS_CERT_FILE}" ] ; then + keytool -importcert -noprompt -keystore "${TRUST_STORE}" -storepass "${STORE_PASS}" -alias cas-ingress -file "${INGRESS_CERT_FILE}" -storetype PKCS12 +else + echo "Missing ingress cert file to put in trust bundle: ${INGRESS_CERT_FILE}" +fi + +# add cas server cert to trust store +if [ -f "${CAS_KEYSTORE}" ] ; then + keytool -exportcert -keystore "${CAS_KEYSTORE}" -storepass "${STORE_PASS}" -alias cas -file "${CAS_CERT_FILE}" -rfc + keytool -importcert -noprompt -storepass "${STORE_PASS}" -keystore "${TRUST_STORE}" -alias cas -file "${CAS_CERT_FILE}" -storetype PKCS12 +else + echo "Missing keystore ${CAS_KEYSTORE} to put cas cert in trust bundle" +fi +kubectl delete configmap cas-truststore --namespace "${NAMESPACE}" || true +kubectl create configmap cas-truststore --namespace "${NAMESPACE}" --from-file=truststore=${TRUST_STORE} \ No newline at end of file diff --git a/helm/delete-cas-server.sh b/helm/delete-cas-server.sh new file mode 100755 index 000000000..3a8269d0a --- /dev/null +++ b/helm/delete-cas-server.sh @@ -0,0 +1,3 @@ +#!/bin/sh +NAMESPACE=${1:-default} +helm delete --namespace "${NAMESPACE}" cas-server \ No newline at end of file diff --git a/helm/install-cas-server-example.sh b/helm/install-cas-server-example.sh new file mode 100755 index 000000000..65ecde059 --- /dev/null +++ b/helm/install-cas-server-example.sh @@ -0,0 +1,5 @@ +#!/bin/sh +NAMESPACE=${1:-default} +EXAMPLE=${2:-example1} + +helm upgrade --install cas-server --values values-${EXAMPLE}.yaml --namespace ${NAMESPACE} ./cas-server diff --git a/helm/install-cas-server.sh b/helm/install-cas-server.sh new file mode 100755 index 000000000..5e94d3a12 --- /dev/null +++ b/helm/install-cas-server.sh @@ -0,0 +1,4 @@ +#!/bin/sh +NAMESPACE=${1:-default} + +helm upgrade --install cas-server --namespace $NAMESPACE ./cas-server \ No newline at end of file diff --git a/helm/values-example1.yaml b/helm/values-example1.yaml new file mode 100644 index 000000000..53e2f73a5 --- /dev/null +++ b/helm/values-example1.yaml @@ -0,0 +1,63 @@ +--- + +# This is example of a values file that can override and add to the default values.yaml +# Deployers might have one or more values files of their own per deployment environment. + +# CAS Server container properties +casServerContainer: + + # override profiles to include gitsvc + profiles: 'standalone,gitsvc' + + ## Override list of config files from casConfig to mount, include some from default values file + casConfigMounts: + - 'cas.properties' + - 'cas.yaml' + - 'application-gitsvc.yaml' + casConfig: + application-gitsvc.yaml: |- + --- + cas: + service-registry: + git: + repository-url: "{{- .Values.gitsvcRepoUrl -}}" + branches-to-clone: "{{- .Values.gitsvcBranchesToClone -}}" + active-branch: "{{- .Values.gitsvcActiveBranch -}}" + clone-directory: "{{- .Values.gitsvcCloneDirectory -}}" + root-directory: "{{- .Values.gitsvcRootDirectory -}}" + #eof + application-redis.yaml: |- + --- + #helm repo add bitnami https://charts.bitnami.com/bitnami + #helm install cas-server-redis bitnami/redis --set usePassword=false --set sentinel.enabled=true --set sentinel.usePassword=false + cas: + ticket: + registry: + redis: + enabled: true + database: 0 + host: 'cas-server-redis' + pool: + test-on-borrow: true + read-from: 'UPSTREAMPREFERRED' + crypto: + enabled: false + timeout: 5000 + port: 6379 + password: ' ' + cluster: + nodes: + - host: 'cas-server-redis-headless' + port: 6379 + password: ' ' + sentinel: + master: 'mymaster' + node: 'cas-server-redis-headless:26379' + # eof + + +gitsvcRepoUrl: 'https://github.com/apereo/cas.git' # need smaller repo with services +gitsvcBranchesToClone: 'master' +gitsvcActiveBranch: 'master' +gitsvcCloneDirectory: '/tmp/cas/services' +gitsvcRootDirectory: 'etc' # only supports one level diff --git a/lombok.config b/lombok.config new file mode 100644 index 000000000..f562841cc --- /dev/null +++ b/lombok.config @@ -0,0 +1,9 @@ +lombok.log.fieldName = LOGGER +lombok.log.fieldIsStatic=true + +lombok.toString.doNotUseGetters=true +lombok.equalsAndHashCode.doNotUseGetters=true + +lombok.addLombokGeneratedAnnotation = true + +config.stopBubbling=true diff --git a/openrewrite.gradle b/openrewrite.gradle new file mode 100644 index 000000000..efa52ef4e --- /dev/null +++ b/openrewrite.gradle @@ -0,0 +1,25 @@ +initscript { + repositories { + gradlePluginPortal() + } + dependencies { + classpath "org.openrewrite:plugin:6.23.0" + } +} + +rootProject { + plugins.apply(org.openrewrite.gradle.RewritePlugin) + dependencies { + rewrite("org.apereo.cas:cas-server-support-openrewrite:${project.targetVersion}") { + transitive = false + } + } + afterEvaluate { + if (repositories.isEmpty()) { + repositories { + mavenLocal() + mavenCentral() + } + } + } +} diff --git a/puppeteer/package.json b/puppeteer/package.json new file mode 100644 index 000000000..59a34dabc --- /dev/null +++ b/puppeteer/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "pino": "9.4.0", + "pino-pretty": "11.2.2", + "puppeteer": "23.3.1" + } +} diff --git a/puppeteer/run.sh b/puppeteer/run.sh new file mode 100644 index 000000000..d5a7632db --- /dev/null +++ b/puppeteer/run.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +CAS_ARGS="${CAS_ARGS:-}" + +RED="\e[31m" +GREEN="\e[32m" +YELLOW="\e[33m" +ENDCOLOR="\e[0m" + +function printgreen() { + printf "${GREEN}$1${ENDCOLOR}\n" +} +function printyellow() { + printf "${YELLOW}$1${ENDCOLOR}\n" +} +function printred() { + printf "${RED}$1${ENDCOLOR}\n" +} + +casWebApplicationFile="${PWD}/build/libs/cas.war" +if [[ ! -f "$casWebApplicationFile" ]]; then + echo "Building CAS" + ./gradlew clean build -x test -x javadoc --no-configuration-cache --offline + if [ $? -ne 0 ]; then + printred "Failed to build CAS" + exit 1 + fi +fi + +if [[ ! -d "${PWD}/puppeteer/node_modules/puppeteer" ]]; then + echo "Installing Puppeteer" + (cd "${PWD}/puppeteer" && npm install puppeteer) +else + echo "Using existing Puppeteer modules..." +fi + +echo -n "NPM version: " && npm --version +echo -n "Node version: " && node --version + +echo "Launching CAS at $casWebApplicationFile with options $CAS_ARGS" +java -jar "$casWebApplicationFile" $CAS_ARGS & +pid=$! +echo "Waiting for CAS under process id ${pid}" +sleep 45 +casLogin="${PUPPETEER_CAS_HOST:-https://localhost:8443}/cas/login" +echo "Checking CAS status at ${casLogin}" +curl -k -L --output /dev/null --silent --fail "$casLogin" +if [[ $? -ne 0 ]]; then + printred "Unable to launch CAS instance under process id ${pid}." + printred "Killing process id $pid and exiting" + kill -9 "$pid" + exit 1 +fi + +export NODE_TLS_REJECT_UNAUTHORIZED=0 +echo "Executing puppeteer scenarios..." +for scenario in "${PWD}"/puppeteer/scenarios/*; do + scenarioName=$(basename "$scenario") + echo "==========================" + echo "- Scenario $scenarioName " + echo -e "==========================\n" + node "$scenario" + rc=$? + echo -e "\n" + if [[ $rc -ne 0 ]]; then + printred "🔥 Scenario $scenarioName FAILED" + else + printgreen "✅ Scenario $scenarioName PASSED" + fi + echo -e "\n" + sleep 1 +done; + +kill -9 "$pid" +exit 0 diff --git a/puppeteer/scenarios/basic.js b/puppeteer/scenarios/basic.js new file mode 100644 index 000000000..10b39594d --- /dev/null +++ b/puppeteer/scenarios/basic.js @@ -0,0 +1,51 @@ +const puppeteer = require('puppeteer'); +const assert = require("assert"); +const pino = require('pino'); +const logger = pino({ + level: "info", + transport: { + target: 'pino-pretty' + } +}); + +(async () => { + const browser = await puppeteer.launch({ + headless: (process.env.CI === "true" || process.env.HEADLESS === "true") ? "new" : false, + ignoreHTTPSErrors: true, + devtools: false, + defaultViewport: null, + slowMo: 5, + args: ['--start-maximized', "--window-size=1920,1080"] + }); + + try { + const page = await browser.newPage(); + + const casHost = process.env.PUPPETEER_CAS_HOST || "https://localhost:8443"; + await page.goto(`${casHost}/cas/login`); + + await page.waitForSelector("#username", {visible: true}); + await page.$eval("#username", el => el.value = ''); + await page.type("#username", "casuser"); + + await page.waitForSelector("#password", {visible: true}); + await page.$eval("#password", el => el.value = ''); + await page.type("#password", "Mellon"); + + await page.keyboard.press('Enter'); + await page.waitForNavigation(); + + const cookies = (await page.cookies()).filter(c => { + logger.debug(`Checking cookie ${c.name}:${c.value}`); + return c.name === "TGC"; + }); + assert(cookies.length !== 0); + logger.info(`Cookie:\n${JSON.stringify(cookies, undefined, 2)}`); + await process.exit(0) + } catch (e) { + logger.error(e); + await process.exit(1) + } finally { + await browser.close(); + } +})(); diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..c15bea350 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,4 @@ +plugins { + id "org.gradle.toolchains.foojay-resolver-convention" version "${gradleFoojayPluginVersion}" +} +rootProject.name = 'cas' diff --git a/src/main/java/org/apereo/cas/config/CasOverlayOverrideConfiguration.java b/src/main/java/org/apereo/cas/config/CasOverlayOverrideConfiguration.java new file mode 100644 index 000000000..eb7ec10bd --- /dev/null +++ b/src/main/java/org/apereo/cas/config/CasOverlayOverrideConfiguration.java @@ -0,0 +1,23 @@ +package org.apereo.cas.config; + +//import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; + +//import org.apereo.cas.configuration.CasConfigurationProperties; + +@AutoConfiguration +//@EnableConfigurationProperties(CasConfigurationProperties.class) +public class CasOverlayOverrideConfiguration { + + /* + @Bean + public MyCustomBean myCustomBean() { + ... + } + */ +} diff --git a/src/main/jib/docker/entrypoint.sh b/src/main/jib/docker/entrypoint.sh new file mode 100755 index 000000000..2747c3fa3 --- /dev/null +++ b/src/main/jib/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +ENTRYPOINT_DEBUG=${ENTRYPOINT_DEBUG:-false} +JVM_DEBUG=${JVM_DEBUG:-false} +JVM_DEBUG_PORT=${JVM_DEBUG_PORT:-5000} +JVM_DEBUG_SUSPEND=${JVM_DEBUG_SUSPEND:-n} +JVM_MEM_OPTS=${JVM_MEM_OPTS:--Xms512m -Xmx4096M} +JVM_EXTRA_OPTS=${JVM_EXTRA_OPTS:--server -noverify -XX:+TieredCompilation -XX:TieredStopAtLevel=1} + +if [ $JVM_DEBUG = "true" ]; then + JVM_EXTRA_OPTS="${JVM_EXTRA_OPTS} -Xdebug -Xrunjdwp:transport=dt_socket,address=*:${JVM_DEBUG_PORT},server=y,suspend=${JVM_DEBUG_SUSPEND}" +fi + +if [ $ENTRYPOINT_DEBUG = "true" ]; then + JVM_EXTRA_OPTS="${JVM_EXTRA_OPTS} -Ddebug=true" + + echo "\nChecking java..." + java -version + + if [ -d /etc/cas ] ; then + echo "\nListing CAS configuration under /etc/cas..." + ls -R /etc/cas + fi + echo "\nRemote debugger configured on port ${JVM_DEBUG_PORT} with suspend=${JVM_DEBUG_SUSPEND}: ${JVM_DEBUG}" + echo "\nJava args: ${JVM_MEM_OPTS} ${JVM_EXTRA_OPTS}" +fi + +echo "\nRunning CAS @ cas.war" +# shellcheck disable=SC2086 +exec java $JVM_EXTRA_OPTS $JVM_MEM_OPTS -jar cas.war "$@" diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..3bc670c2c --- /dev/null +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.apereo.cas.config.CasOverlayOverrideConfiguration diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..cebe337d4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +# Application properties that need to be +# embedded within the web application can be included here + diff --git a/system.properties b/system.properties new file mode 100644 index 000000000..5a9b50d86 --- /dev/null +++ b/system.properties @@ -0,0 +1 @@ +java.runtime.version=21