├── .github ├── test.sh └── workflows │ └── docker.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── dex.yaml ├── run.js └── traefik.yaml /.github/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | # Does a very simple smoke test to make sure Grist API is available 5 | # In .github directory just to avoid adding more files/directories 6 | # in root :-) 7 | 8 | IMAGE=gristlabs/grist-omnibus 9 | TEAM=cool-beans 10 | PORT=9998 11 | 12 | mkdir -p /tmp/omnibus-test 13 | docker run --rm --name grist \ 14 | -e URL=http://localhost:$PORT \ 15 | -v /tmp/omnibus-test:/persist \ 16 | -e EMAIL=owner@example.com \ 17 | -e PASSWORD=topsecret \ 18 | -e TEAM=$TEAM \ 19 | -p $PORT:80 \ 20 | -d $IMAGE 21 | 22 | function finish { 23 | docker logs grist || echo no logs 24 | docker kill grist > /dev/null 25 | } 26 | trap finish EXIT 27 | 28 | for ct in $(seq 1 20); do 29 | echo "Check $ct" 30 | check="$(curl http://localhost:$PORT/api/orgs || echo fail)" 31 | if [[ "$check" = "[]" ]]; then 32 | echo Grist is responsive 33 | exit 0 34 | fi 35 | sleep 1 36 | done 37 | 38 | echo "Grist did not respond" 39 | exit 1 40 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Push Docker image 2 | 3 | on: 4 | push: 5 | branches: [ release ] 6 | schedule: 7 | # Run at 6:41 UTC on Thursday 8 | - cron: '41 6 * * 4' 9 | workflow_dispatch: 10 | 11 | jobs: 12 | push_to_registry: 13 | name: Push latest Docker image to Docker Hub 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check out the repo 17 | uses: actions/checkout@v2 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | - name: Log in to Docker Hub 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | - name: Build grist-omnibus for testing 28 | uses: docker/build-push-action@v2 29 | with: 30 | pull: true 31 | load: true 32 | tags: ${{ github.repository_owner }}/grist-omnibus:latest 33 | cache-from: type=gha 34 | cache-to: type=gha,mode=max 35 | - name: Do a smoke test on grist-omnibus 36 | run: make test 37 | - name: Push grist-omnibus to Docker Hub 38 | uses: docker/build-push-action@v2 39 | with: 40 | platforms: linux/amd64,linux/arm64/v8 41 | push: true 42 | tags: ${{ github.repository_owner }}/grist-omnibus:latest 43 | cache-from: type=gha 44 | cache-to: type=gha,mode=max 45 | - name: Push grist-ee-omnibus to Docker Hub 46 | uses: docker/build-push-action@v2 47 | with: 48 | build-args: | 49 | BASE=gristlabs/grist-ee:latest 50 | platforms: linux/amd64,linux/arm64/v8 51 | pull: true 52 | push: true 53 | tags: ${{ github.repository_owner }}/grist-ee-omnibus:latest 54 | cache-from: type=gha 55 | cache-to: type=gha,mode=max 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################################################ 2 | # Grist omnibus image 3 | # Grist doesn't have a built-in login system, which can be 4 | # a stumbling block for beginners or people just wanting to 5 | # try it out. 6 | # Includes bundled traefik, traefik-forward-auth, and dex. 7 | 8 | ARG BASE=gristlabs/grist:latest 9 | 10 | # Gather main dependencies. 11 | FROM dexidp/dex:v2.33.1 as dex 12 | FROM traefik:2.8 as traefik 13 | FROM traefik/whoami as whoami 14 | 15 | # recent public traefik-forward-auth image doesn't support arm, 16 | # so build it from scratch. 17 | FROM golang:1.13-alpine as fwd 18 | RUN mkdir -p /go/src/github.com/thomseddon/traefik-forward-auth 19 | WORKDIR /go/src/github.com/thomseddon/traefik-forward-auth 20 | RUN apk add --no-cache git 21 | RUN mkdir -p /go/src/github.com/thomseddon/ 22 | RUN cd /go/src/github.com/thomseddon/ && \ 23 | git clone https://github.com/thomseddon/traefik-forward-auth.git && \ 24 | cd traefik-forward-auth && \ 25 | git checkout c4317b7503fb0528d002eb1e5ee43c4a37f055d0 26 | ARG TARGETOS TARGETARCH 27 | RUN echo "Compiling for [$TARGETOS $TARGETARCH] (will be blank if not using BuildKit)" 28 | RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH GO111MODULE=on go build -a -installsuffix nocgo \ 29 | -o /traefik-forward-auth github.com/thomseddon/traefik-forward-auth/cmd 30 | 31 | # Extend Grist image. 32 | FROM $BASE as merge 33 | 34 | # Enable sandboxing by default. It is generally important when sharing with 35 | # others. You may override it, e.g. "unsandboxed" uses no sandboxing but is 36 | # only OK if you trust all users fully. 37 | ENV GRIST_SANDBOX_FLAVOR=gvisor 38 | 39 | # apache2-utils is for htpasswd, used with dex 40 | RUN \ 41 | apt-get update && \ 42 | apt-get install -y --no-install-recommends pwgen apache2-utils curl && \ 43 | apt-get install -y --no-install-recommends ca-certificates tzdata && \ 44 | rm -rf /var/lib/apt/lists/* 45 | 46 | # Copy in traefik-forward-auth program. 47 | COPY --from=fwd /traefik-forward-auth /usr/local/bin 48 | 49 | # Copy in traeefik program. 50 | COPY --from=traefik /usr/local/bin/traefik /usr/local/bin/traefik 51 | 52 | # Copy in all of dex parts, including its funky template-expanding 53 | # entrypoint (rename this to dex-entrypoint). 54 | COPY --from=dex /var/dex /var/dex 55 | COPY --from=dex /etc/dex /etc/dex 56 | COPY --from=dex /usr/local/src/dex/ /usr/local/src/dex/ 57 | COPY --from=dex /usr/local/bin/dex /usr/local/bin/dex 58 | COPY --from=dex /srv/dex/web /srv/dex/web 59 | COPY --from=dex /usr/local/bin/gomplate /usr/local/bin/gomplate 60 | COPY --from=dex /usr/local/bin/docker-entrypoint /usr/local/bin/dex-entrypoint 61 | 62 | COPY --from=whoami /whoami /usr/local/bin/whoami 63 | 64 | COPY dex.yaml /settings/dex.yaml 65 | COPY traefik.yaml /settings/traefik.yaml 66 | COPY run.js /grist/run.js 67 | 68 | # Make traefik-forward-auth trust self-signed certificates internally, if user 69 | # chooses to use one. 70 | RUN ln -s /custom/grist.crt /etc/ssl/certs/grist.pem 71 | 72 | # Squashing this way loses environment variables set in base image 73 | # so we need to revert it for now. 74 | # # One last layer, to squash everything. 75 | # FROM scratch 76 | # COPY --from=merge / / 77 | 78 | CMD /grist/run.js 79 | 80 | EXPOSE 80 443 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PORT = 8484 2 | TEAM = cool-beans 3 | 4 | # Possible bases: gristlabs/grist, or gristlabs/grist-ee 5 | BASE = gristlabs/grist 6 | IMAGE = $(BASE)-omnibus 7 | 8 | build: 9 | docker build --build-arg BASE=$(BASE) -t $(IMAGE) . 10 | 11 | run: 12 | mkdir -p /tmp/omnibus 13 | docker run --rm --name grist \ 14 | -e URL=http://localhost:$(PORT) \ 15 | -v /tmp/omnibus:/persist \ 16 | -e EMAIL=owner@example.com \ 17 | -e PASSWORD=topsecret \ 18 | -e TEAM=$(TEAM) \ 19 | -p $(PORT):80 \ 20 | -it $(IMAGE) 21 | 22 | push: 23 | docker push $(IMAGE) 24 | 25 | buildwitharch: 26 | DOCKER_BUILDKIT=1 docker buildx build \ 27 | --platform linux/amd64,linux/arm64 \ 28 | --build-arg BASE=$(BASE) \ 29 | -t $(IMAGE) . 30 | DOCKER_BUILDKIT=1 docker buildx build \ 31 | --build-arg BASE=$(BASE) \ 32 | -t $(IMAGE) --load . 33 | 34 | pushwitharch: 35 | DOCKER_BUILDKIT=1 docker buildx build \ 36 | --platform linux/amd64,linux/arm64 \ 37 | --build-arg BASE=$(BASE) \ 38 | -t $(IMAGE) --push . 39 | 40 | test: 41 | ./.github/test.sh 42 | 43 | makecert: 44 | @echo "Put grist.example.com in your /etc/hosts as 127.0.0.1, and make a self-signed cert for it" 45 | openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 365 -nodes 46 | 47 | runwithcert: 48 | mkdir -p /tmp/omnibus 49 | docker run --rm --name grist \ 50 | -e URL=https://grist.example.com:$(PORT) \ 51 | -e HTTPS=manual \ 52 | -v $(PWD)/key.pem:/custom/grist.key \ 53 | -v $(PWD)/cert.pem:/custom/grist.crt \ 54 | -v /tmp/omnibus:/persist \ 55 | -e EMAIL=owner@example.com \ 56 | -e PASSWORD=topsecret \ 57 | -e TEAM=$(TEAM) \ 58 | -p $(PORT):443 \ 59 | --add-host grist.example.com:$(shell docker network inspect --format='{{range .IPAM.Config}}{{.Gateway}}{{end}}' bridge) \ 60 | -it $(IMAGE) 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Grist Omnibus 2 | ============= 3 | 4 | This is an experimental way to install Grist on a server 5 | quickly with authentication and certificate handling set up 6 | out of the box. Grist Labs also has [marketplace offerings](https://support.getgrist.com/install/grist-builder-edition/) 7 | for [AWS](https://support.getgrist.com/install/grist-builder-edition/#aws) and [Azure](https://support.getgrist.com/install/grist-builder-edition/#azure). 8 | 9 | So you and your colleagues can log in: 10 | ![Screenshot from 2022-08-16 18-14-16](https://user-images.githubusercontent.com/118367/184994955-df9359d6-86b3-4147-9214-058b2c8c5fe7.png) 11 | 12 | And use Grist without fuss: 13 | ![Screenshot from 2022-08-16 18-16-38](https://user-images.githubusercontent.com/118367/184995003-aa4ae6e7-6a05-420f-98a8-36b465bc2a81.png) 14 | 15 | It bundles: 16 | 17 | * Grist itself from [grist-core](https://github.com/gristlabs/grist-core/) - 18 | Grist is a handy spreadsheet / online database app, 19 | presumably you like it and that's why you are here. 20 | * A reverse proxy, [Traefik](https://github.com/traefik/traefik) - 21 | we use this to coordinate with Let's Encrypt to get a 22 | certificate for https traffic. 23 | * An identity service, [Dex](https://github.com/dexidp/dex/) - 24 | this can connect to LDAP servers, SAML providers, Google, 25 | Microsoft, etc, and also (somewhat reluctantly) supports 26 | hard-coded user/passwords that can be handy for a quick 27 | fuss-free test. 28 | * An authentication middleware, [traefik-forward-auth](https://github.com/thomseddon/traefik-forward-auth) to 29 | connect Grist and Dex via Traefik. 30 | 31 | Here's the minimal configuration you need to provide. 32 | * `EMAIL`: an email address, used for Let's Encrypt and for 33 | initial login. 34 | * `PASSWORD`: optional - if you set this, you'll be able to 35 | log in without configuring any other authentication 36 | settings. You can add more accounts as `EMAIL2`, 37 | `PASSWORD2`, `EMAIL3`, `PASSWORD3` etc. 38 | * `TEAM` - a short lowercase identifier, such as a company or project name 39 | (`grist-labs`, `cool-beans`). Just `a-z`, `0-9` and 40 | `-` characters please. 41 | * `URL` - this is important, you need to provide the base 42 | URL at which Grist will be accessed. It could be something 43 | like `https://grist.example.com`, or `http://localhost:9999`. 44 | No path element please. 45 | * `HTTPS` - mandatory if `URL` is `https` protocol. Can be 46 | `auto` (Let's Encrypt) if Grist is publically accessible and 47 | you're cool with automatically getting a certificate from 48 | Let's Encrypt. Otherwise use `external` if you are dealing 49 | with ssl termination yourself after all, or `manual` if you want 50 | to provide a certificate you've prepared yourself (there's an 51 | example below). 52 | 53 | The minimal storage needed is an empty directory mounted 54 | at `/persist`. 55 | 56 | So here is a complete docker invocation that would work on a public 57 | instance with ports 80 and 443 available: 58 | ``` 59 | mkdir -p /tmp/grist-test 60 | docker run \ 61 | -p 80:80 -p 443:443 \ 62 | -e URL=https://cool-beans.example.com \ 63 | -e HTTPS=auto \ 64 | -e TEAM=cool-beans \ 65 | -e EMAIL=owner@example.com \ 66 | -e PASSWORD=topsecret \ 67 | -v /tmp/grist-test:/persist \ 68 | --name grist --rm \ 69 | -it gristlabs/grist-omnibus # or grist-ee-omnibus for enterprise 70 | ``` 71 | 72 | And here is an invocation on localhost port 9999 - the only 73 | differences are the `-p` port configuration and the `-e URL=` environment 74 | variable. 75 | ``` 76 | mkdir -p /tmp/grist-test 77 | docker run \ 78 | -p 9999:80 \ 79 | -e URL=http://localhost:9999 \ 80 | -e TEAM=cool-beans \ 81 | -e EMAIL=owner@example.com \ 82 | -e PASSWORD=topsecret \ 83 | -v /tmp/grist-test:/persist \ 84 | --name grist --rm \ 85 | -it gristlabs/grist-omnibus # or grist-ee-omnibus for enterprise 86 | ``` 87 | 88 | If providing your own certificate (`HTTPS=manual`), provide a 89 | private key and certificate file as `/custom/grist.key` and 90 | `custom/grist.crt` respectively: 91 | 92 | ``` 93 | docker run \ 94 | ... 95 | -e HTTPS=manual \ 96 | -v $(PWD)/key.pem:/custom/grist.key \ 97 | -v $(PWD)/cert.pem:/custom/grist.crt \ 98 | ... 99 | ``` 100 | 101 | Remember if you are on a public server you don't need to do this, you can 102 | set `HTTPS=auto` and have Traefik + Let's Encrypt do the work for you. 103 | 104 | If you run the omnibus behind a separate reverse proxy that terminates SSL, then you should 105 | `HTTPS=external`, and set an additional environment variable `TRUSTED_PROXY_IPS` to the IP 106 | address or IP range of the proxy. This may be a comma-separated list, e.g. 107 | `127.0.0.1/32,192.168.1.7`. See Traefik's [forwarded 108 | headers](https://doc.traefik.io/traefik/routing/entrypoints/#forwarded-headers). 109 | 110 | You can change `dex.yaml` (for example, to fill in keys for Google 111 | and Microsoft sign-ins, or to remove them) and then either rebuild 112 | the image or (easier) make the custom settings available to the omnibus 113 | as `/custom/dex.yaml`: 114 | 115 | ``` 116 | docker run \ 117 | ... 118 | -v $PWD/dex.yaml:/custom/dex.yaml \ 119 | ... 120 | ``` 121 | 122 | You can tell it is being used because `Using /custom/dex.yaml` will 123 | be printed instead of `No /custom/dex.yaml`. 124 | -------------------------------------------------------------------------------- /dex.yaml: -------------------------------------------------------------------------------- 1 | issuer: '{{ getenv "APP_HOME_URL" }}/dex' 2 | 3 | storage: 4 | type: sqlite3 5 | config: 6 | file: /persist/auth/dex.db 7 | 8 | web: 9 | http: '0.0.0.0:{{ getenv "DEX_PORT" }}' 10 | 11 | logger: 12 | level: "debug" 13 | format: "text" 14 | 15 | frontend: 16 | issuer: "Grist" 17 | logoURL: '{{ getenv "APP_HOME_URL" }}/v/unknown/ui-icons/Logo/GristLogo.svg' 18 | 19 | staticClients: 20 | - id: '{{ getenv "PROVIDERS_OIDC_CLIENT_ID" }}' 21 | redirectURIs: 22 | - '{{ getenv "APP_HOME_URL" }}/_oauth' 23 | name: 'Grist' 24 | secret: '{{ getenv "PROVIDERS_OIDC_CLIENT_SECRET" }}' 25 | 26 | 27 | oauth2: 28 | skipApprovalScreen: true 29 | 30 | connectors: 31 | 32 | - type: google 33 | id: google 34 | name: Google 35 | config: 36 | issuer: https://accounts.google.com 37 | clientID: PUT_CLIENT_ID_HERE_PLEASE 38 | clientSecret: PUT_GOOGLE_CLIENT_SECRET_HERE_PLEASE 39 | redirectURI: '{{ getenv "APP_HOME_URL" }}/dex/callback' 40 | 41 | - type: microsoft 42 | id: microsoft 43 | name: Microsoft 44 | config: 45 | clientID: PUT_CLIENT_ID_HERE_PLEASE 46 | clientSecret: PUT_CLIENT_SECRET_HERE_PLEASE 47 | redirectURI: '{{ getenv "APP_HOME_URL" }}/dex/callback' 48 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const child_process = require('child_process'); 5 | const colors = require('colors/safe'); 6 | const commander = require('commander'); 7 | const fetch = require('node-fetch'); 8 | const https = require('https'); 9 | const path = require('path'); 10 | 11 | function consoleLogger(level, color) { 12 | return (message, ...args) => console.log(color(level) + ' [grist-omnibus] ' + message, ...args); 13 | } 14 | const log = { 15 | debug: consoleLogger('debug', colors.blue), 16 | info: consoleLogger('info', colors.green), 17 | warn: consoleLogger('warn', colors.yellow), 18 | error: consoleLogger('error', colors.red), 19 | }; 20 | 21 | 22 | async function main() { 23 | const {program} = commander; 24 | program.option('-p, --part '); 25 | program.parse(); 26 | const options = program.opts(); 27 | const part = options.part || 'all'; 28 | 29 | prepareDirectories(); 30 | prepareMainSettings(); 31 | prepareNetworkSettings(); 32 | prepareCertificateSettings(); 33 | if (part === 'grist' || part === 'all') { 34 | startGrist(); 35 | } 36 | if (part === 'traefik' || part === 'all') { 37 | startTraefik(); 38 | } 39 | if (part === 'who' || part === 'all') { 40 | startWho(); 41 | } 42 | if (part === 'dex' || part === 'all') { 43 | startDex(); 44 | } 45 | if (part === 'tfa' || part === 'all') { 46 | await waitForDex(); 47 | startTfa(); 48 | } 49 | await sleep(1000); 50 | log.info('I think everything has started up now'); 51 | if (part === 'all') { 52 | const ports = process.env.HTTPS ? '80/443' : '80'; 53 | log.info(`Listening internally on ${ports}, externally at ${process.env.URL}`); 54 | } 55 | } 56 | 57 | main().catch(e => log.error(e)); 58 | 59 | function prepareDirectories() { 60 | fs.mkdirSync('/persist/auth', { recursive: true }); 61 | } 62 | 63 | function essentialProcess(label, childProcess) { 64 | function fail(err) { 65 | log.error(`${label} failed: ${err.message}`); 66 | process.exit(1); 67 | } 68 | childProcess.on('error', (err) => fail(err)); 69 | childProcess.on('exit', (code, signal) => fail(new Error(`exited with ${signal || code}`))); 70 | } 71 | 72 | function startGrist() { 73 | essentialProcess("grist", child_process.spawn('/grist/sandbox/run.sh', { 74 | env: { 75 | ...process.env, 76 | PORT: process.env.GRIST_PORT, 77 | }, 78 | cwd: '/grist', 79 | stdio: 'inherit', 80 | detached: true, 81 | })); 82 | } 83 | 84 | function startTraefik() { 85 | const flags = []; 86 | flags.push("--providers.file.filename=/settings/traefik.yaml"); 87 | flags.push("--entryPoints.web.address=:80") 88 | 89 | if (process.env.HTTPS === 'auto') { 90 | flags.push(`--certificatesResolvers.letsencrypt.acme.email=${process.env.EMAIL}`) 91 | flags.push("--certificatesResolvers.letsencrypt.acme.storage=/persist/acme.json") 92 | flags.push("--certificatesResolvers.letsencrypt.acme.tlschallenge=true") 93 | } 94 | if (process.env.HTTPS) { 95 | flags.push("--entrypoints.websecure.address=:443") 96 | // Redirect http -> https 97 | // See: https://doc.traefik.io/traefik/routing/entrypoints/#redirection 98 | flags.push("--entrypoints.web.http.redirections.entrypoint.scheme=https") 99 | flags.push("--entrypoints.web.http.redirections.entrypoint.to=websecure") 100 | } 101 | let TFA_TRUST_FORWARD_HEADER = 'false'; 102 | if (process.env.TRUSTED_PROXY_IPS) { 103 | flags.push(`--entryPoints.web.forwardedHeaders.trustedIPs=${process.env.TRUSTED_PROXY_IPS}`) 104 | TFA_TRUST_FORWARD_HEADER = 'true'; 105 | } 106 | log.info("Calling traefik", flags); 107 | essentialProcess("traefik", child_process.spawn('traefik', flags, { 108 | env: {...process.env, TFA_TRUST_FORWARD_HEADER}, 109 | stdio: 'inherit', 110 | detached: true, 111 | })); 112 | } 113 | 114 | function startDex() { 115 | let txt = fs.readFileSync('/settings/dex.yaml', { encoding: 'utf-8' }); 116 | txt += addDexUsers(); 117 | const customFile = '/custom/dex.yaml'; 118 | if (fs.existsSync(customFile)) { 119 | log.info(`Using ${customFile}`) 120 | txt = fs.readFileSync(customFile, { encoding: 'utf-8' }); 121 | } else { 122 | log.info(`No ${customFile}`) 123 | } 124 | fs.writeFileSync('/persist/dex-full.yaml', txt, { encoding: 'utf-8' }); 125 | essentialProcess("dex", child_process.spawn('dex-entrypoint', [ 126 | 'dex', 'serve', '/persist/dex-full.yaml' 127 | ], { 128 | env: process.env, 129 | stdio: 'inherit', 130 | detached: true, 131 | })); 132 | } 133 | 134 | function startTfa() { 135 | log.info('Starting traefik-forward-auth'); 136 | essentialProcess("traefik-forward-auth", child_process.spawn('traefik-forward-auth', [ 137 | `--port=${process.env.TFA_PORT}` 138 | ], { 139 | env: process.env, 140 | stdio: 'inherit', 141 | detached: true, 142 | })); 143 | } 144 | 145 | function startWho() { 146 | child_process.spawn('whoami', { 147 | env: { 148 | ...process.env, 149 | WHOAMI_PORT_NUMBER: process.env.WHOAMI_PORT, 150 | }, 151 | stdio: 'inherit', 152 | detached: true, 153 | }); 154 | } 155 | 156 | function prepareMainSettings() { 157 | // By default, hide UI elements that require a lot of setup. 158 | setDefaultEnv('GRIST_HIDE_UI_ELEMENTS', 'helpCenter,billing,templates,multiSite,multiAccounts'); 159 | 160 | // Support URL as a synonym of APP_HOME_URL, and make it mandatory. 161 | setSynonym('URL', 'APP_HOME_URL'); 162 | if (!process.env.URL) { 163 | throw new Error('Please define URL so Grist knows how users will access it.'); 164 | } 165 | 166 | // Support EMAIL as a synonym of GRIST_DEFAULT_EMAIL, and make it mandatory. 167 | setSynonym('EMAIL', 'GRIST_DEFAULT_EMAIL'); 168 | if (!process.env.EMAIL) { 169 | throw new Error('Please provide an EMAIL, needed for certificates and initial login.'); 170 | } 171 | 172 | // Support TEAM as a synonym of GRIST_SINGLE_ORG, and make it mandatory for now. 173 | // Working with multiple teams is possible but a little harder to explain 174 | // and understand, and the UI has rough edges. 175 | setSynonym('TEAM', 'GRIST_SINGLE_ORG'); 176 | if (!process.env.TEAM) { 177 | throw new Error('Please set TEAM, omnibus version of Grist expects it.'); 178 | } 179 | setDefaultEnv('GRIST_ORG_IN_PATH', 'false'); 180 | 181 | setDefaultEnv('GRIST_FORWARD_AUTH_HEADER', 'X-Forwarded-User'); 182 | setBrittleEnv('GRIST_FORWARD_AUTH_LOGOUT_PATH', '_oauth/logout'); 183 | setDefaultEnv('GRIST_FORCE_LOGIN', 'true'); 184 | 185 | if (!process.env.GRIST_SESSION_SECRET) { 186 | process.env.GRIST_SESSION_SECRET = invent('GRIST_SESSION_SECRET'); 187 | } 188 | 189 | // When not using https either manually or via automation, the user 190 | // presumably will tolerate cookies sent without https. See: 191 | // https://community.getgrist.com/t/solved-local-use-without-https/2852/11 192 | if (!process.env.HTTPS && !process.env.INSECURE_COOKIE) { 193 | // see https://github.com/thomseddon/traefik-forward-auth for 194 | // documentation. This environment variable will be set when 195 | // the traefik-forward-auth process is started (and others too, 196 | // but won't have an impact on them). 197 | process.env.INSECURE_COOKIE = 'true'; 198 | } 199 | } 200 | 201 | function prepareNetworkSettings() { 202 | const url = new URL(process.env.URL); 203 | process.env.APP_HOST = url.hostname || 'localhost'; 204 | // const extPort = parseInt(url.port || '9999', 10); 205 | const extPort = url.port || '9999'; 206 | process.env.EXT_PORT = extPort; 207 | 208 | // traefik-forward-auth will try to talk directly to dex, so it is 209 | // important that URL works internally, withing the container. But 210 | // if URL contains localhost, it really won't. We can finess that 211 | // by tying DEX_PORT to EXT_PORT in that case. As long as it isn't 212 | // 80 or 443, since traefik is listening there... 213 | 214 | process.env.DEX_PORT = '9999'; 215 | if (process.env.APP_HOST === 'localhost' && extPort !== '80' && extPort !== '443') { 216 | process.env.DEX_PORT = process.env.EXT_PORT; 217 | } 218 | 219 | // Keep other ports out of the way of Dex port. 220 | const alt = String(process.env.DEX_PORT).charAt(0) === '1' ? '2' : '1'; 221 | process.env.GRIST_PORT = `${alt}7100`; 222 | process.env.TFA_PORT = `${alt}7101`; 223 | process.env.WHOAMI_PORT = `${alt}7102`; 224 | 225 | setBrittleEnv('DEFAULT_PROVIDER', 'oidc'); 226 | process.env.PROVIDERS_OIDC_CLIENT_ID = invent('PROVIDERS_OIDC_CLIENT_ID'); 227 | process.env.PROVIDERS_OIDC_CLIENT_SECRET = invent('PROVIDERS_OIDC_CLIENT_SECRET'); 228 | process.env.PROVIDERS_OIDC_ISSUER_URL = `${process.env.APP_HOME_URL}/dex`; 229 | process.env.SECRET = invent('TFA_SECRET'); 230 | process.env.LOGOUT_REDIRECT = `${process.env.APP_HOME_URL}/signed-out`; 231 | } 232 | 233 | function setSynonym(name1, name2) { 234 | if (process.env[name1] && process.env[name2] && process.env[name1] !== process.env[name2]) { 235 | throw new Error(`${name1} and ${name2} are synonyms and should be the same`); 236 | } 237 | if (process.env[name1]) { setDefaultEnv(name2, process.env[name1]); } 238 | if (process.env[name2]) { setDefaultEnv(name1, process.env[name2]); } 239 | } 240 | 241 | // Set a default for an environment variable. 242 | function setDefaultEnv(name, value) { 243 | if (process.env[name] === undefined) { 244 | process.env[name] = value; 245 | } 246 | } 247 | 248 | function setBrittleEnv(name, value) { 249 | if (process.env[name] !== undefined && process.env[name] !== value) { 250 | throw new Error(`Sorry, we need to set ${name} (we want to set it to ${value})`); 251 | } 252 | process.env[name] = value; 253 | } 254 | 255 | function invent(key) { 256 | const dir = '/persist/params'; 257 | fs.mkdirSync(dir, { recursive: true }); 258 | const fname = path.join(dir, key); 259 | if (!fs.existsSync(fname)) { 260 | const val = child_process.execSync('pwgen -s 20', { encoding: 'utf-8' }); 261 | fs.writeFileSync(fname, val.trim(), { encoding: 'utf-8' }); 262 | } 263 | return fs.readFileSync(fname, { encoding: 'utf-8' }).trim(); 264 | } 265 | 266 | function addDexUsers() { 267 | 268 | let hasEmail = false; 269 | const txt = []; 270 | 271 | function activate() { 272 | if (hasEmail) { return; } 273 | hasEmail = true; 274 | txt.push("enablePasswordDB: true"); 275 | txt.push("staticPasswords:"); 276 | } 277 | 278 | function deactivate() { 279 | if (!hasEmail) { return; } 280 | txt.push(""); 281 | } 282 | 283 | function emit(user) { 284 | activate(); 285 | txt.push(`- email: "${user.email}"`); 286 | txt.push(` hash: "${user.hash}"`); 287 | } 288 | 289 | function go(suffix) { 290 | var emailKey = 'EMAIL' + suffix; 291 | var passwordKey = 'PASSWORD' + suffix; 292 | const email = process.env[emailKey]; 293 | if (!email) { return false; } 294 | const passwd = process.env[passwordKey]; 295 | if (!passwd) { 296 | log.warn(`Found ${emailKey} without a matching ${passwordKey}, skipping`); 297 | return true; 298 | } 299 | const hash = child_process.execSync('htpasswd -BinC 10 no_username', { input: passwd, encoding: 'utf-8' }).split(':')[1].trim(); 300 | emit({ email, hash }); 301 | return true; 302 | } 303 | 304 | go(''); 305 | go('0'); 306 | go('1'); 307 | let i = 2; 308 | while (go(String(i))) { 309 | i++; 310 | } 311 | deactivate(); 312 | return txt.join('\n') + '\n'; 313 | } 314 | 315 | async function waitForDex() { 316 | const fetchOptions = process.env.HTTPS ? { 317 | agent: new https.Agent({ 318 | // If we are responsible for certs, wait for them to be 319 | // set up and valid - don't accept default self-signed 320 | // traefik certs. Otherwise traefik-forward-auth will 321 | // fail immediately if it sees a self-signed cert, without 322 | // giving letsencrypt time to make one for us. 323 | // 324 | // Otherwise, don't fret, the responsibility for certs 325 | // being in place before the rest of grist-omnibus starts 326 | // lies elsewhere. We only care if dex is up and running. 327 | rejectUnauthorized: (process.env.HTTPS === 'auto'), 328 | }) 329 | } : {}; 330 | let delay = 0.1; 331 | while (true) { 332 | const url = process.env.PROVIDERS_OIDC_ISSUER_URL + '/.well-known/openid-configuration'; 333 | log.info(`Checking dex... at ${url}`); 334 | try { 335 | const result = await fetch(url, fetchOptions); 336 | log.debug(` got: ${result.status}`); 337 | if (result.status === 200) { break; } 338 | } catch (e) { 339 | log.debug(` not ready: ${e}`); 340 | } 341 | await sleep(1000 * delay); 342 | delay = Math.min(5.0, delay * 1.2); 343 | } 344 | log.info("Happy with dex"); 345 | } 346 | 347 | function sleep(ms) { 348 | return new Promise((resolve) => { 349 | setTimeout(resolve, ms); 350 | }); 351 | } 352 | 353 | function prepareCertificateSettings() { 354 | const url = new URL(process.env.URL); 355 | if (url.protocol === 'https:') { 356 | const https = String(process.env.HTTPS); 357 | if (!['auto', 'external', 'manual'].includes(https)) { 358 | throw new Error(`HTTPS environment variable must be set to: auto, external, or manual.`); 359 | } 360 | const tls = (https === 'auto') ? '{ certResolver: letsencrypt }' : 361 | (https === 'manual') ? 'true' : 'false'; 362 | process.env.TLS = tls; 363 | process.env.USE_HTTPS = 'true'; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /traefik.yaml: -------------------------------------------------------------------------------- 1 | http: 2 | services: 3 | grist: 4 | loadBalancer: 5 | servers: 6 | - url: 'http://127.0.0.1:{{ env "GRIST_PORT" }}' 7 | dex: 8 | loadBalancer: 9 | servers: 10 | - url: 'http://127.0.0.1:{{ env "DEX_PORT" }}' 11 | tfa: 12 | loadBalancer: 13 | servers: 14 | - url: 'http://127.0.0.1:{{ env "TFA_PORT" }}' 15 | whoami: 16 | loadBalancer: 17 | servers: 18 | - url: 'http://127.0.0.1:{{ env "WHOAMI_PORT" }}' 19 | 20 | middlewares: 21 | tfa: 22 | forwardauth: 23 | address: 'http://127.0.0.1:{{ env "TFA_PORT" }}' 24 | authResponseHeaders: [ '{{ env "GRIST_FORWARD_AUTH_HEADER" }}' ] 25 | trustForwardHeader: '{{ env "TFA_TRUST_FORWARD_HEADER" }}' 26 | no-fwd: 27 | headers: 28 | customRequestHeaders: 29 | '{{ env "GRIST_FORWARD_AUTH_HEADER" }}': "" 30 | 31 | routers: 32 | route-grist-login: 33 | rule: "PathPrefix(`/auth/login`) || PathPrefix(`/_oauth`)" 34 | service: grist 35 | middlewares: 36 | - tfa 37 | entryPoints: 38 | - web 39 | 40 | route-grist: 41 | rule: "PathPrefix(`/`)" 42 | priority: 1 # Set a lower priority than the other rules 43 | service: grist 44 | middlewares: 45 | - no-fwd 46 | entryPoints: 47 | - web 48 | 49 | route-dex: 50 | rule: "PathPrefix(`/dex/`) || Path(`/dex`)" 51 | service: dex 52 | entryPoints: 53 | - web 54 | 55 | route-who: 56 | rule: "Path(`/who`)" 57 | service: whoami 58 | entryPoints: 59 | - web 60 | 61 | {{ $use_https := env "USE_HTTPS" }} 62 | {{if eq $use_https "true" }} 63 | https-route-grist-login: 64 | rule: "Host(`{{ env "APP_HOST" }}`) && (PathPrefix(`/auth/login`) || PathPrefix(`/_oauth`))" 65 | service: grist 66 | middlewares: 67 | - tfa 68 | entryPoints: 69 | - websecure 70 | tls: {{ env "TLS" }} 71 | 72 | https-route-grist: 73 | rule: "Host(`{{ env "APP_HOST" }}`) && PathPrefix(`/`)" 74 | priority: 1 # Set a lower priority than the other rules 75 | service: grist 76 | middlewares: 77 | - no-fwd 78 | entryPoints: 79 | - websecure 80 | tls: {{ env "TLS" }} 81 | 82 | https-route-dex: 83 | rule: "Host(`{{ env "APP_HOST" }}`) && (PathPrefix(`/dex/`) || Path(`/dex`))" 84 | service: dex 85 | entryPoints: 86 | - websecure 87 | tls: {{ env "TLS" }} 88 | 89 | https-route-who: 90 | rule: "Host(`{{ env "APP_HOST" }}`) && Path(`/who`)" 91 | service: whoami 92 | entryPoints: 93 | - websecure 94 | tls: true 95 | {{end}} 96 | 97 | 98 | {{ $https := env "HTTPS" }} 99 | {{if eq $https "manual"}} 100 | tls: 101 | stores: 102 | default: 103 | defaultCertificate: 104 | certFile: /custom/grist.crt 105 | keyFile: /custom/grist.key 106 | {{end}} 107 | --------------------------------------------------------------------------------