diff --git a/.dockerignore b/.dockerignore index 3ca50c4..4b4f8d3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,15 @@ # **/name - exclude file/dir "name" in project root or any subdir # !name - include previously excluded "name" # a*b?c/d[0-9]e[^a-z\]]f\[g - pattern -* -!bin -!ms/auth/internal/migrations/*.sql -!ms/example/internal/migrations/*.sql +.circleci +.gitattributes +.github +.gitignore +.gobincache + +bin +Dockerfile +docker-compose.yml +docs +README.md +#scripts diff --git a/Dockerfile b/Dockerfile index 002a5bf..8df06fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,32 @@ -FROM alpine:3.13 +FROM golang:1.16 as prepare + +ENV GO111MODULE on +ENV GOBIN "/usr/local/bin" +ENV CGO_ENABLED 0 + +WORKDIR /app + +COPY . . + +RUN go install github.com/bufbuild/buf/cmd/buf@v1.1.0 +RUN go install \ + github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \ + github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \ + google.golang.org/protobuf/cmd/protoc-gen-go \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc + +FROM prepare as build + +ARG MONO_VERSION="latest" +ENV BUILD_VERSION "${MONO_VERSION}" + +WORKDIR /app + +RUN go generate ./api/... + +RUN go build -ldflags "-X '$(go list -m)/pkg/def.ver=${BUILD_VERSION}'" -o bin/ ./cmd/mono + +FROM alpine:3.13 as runner LABEL org.opencontainers.image.source="https://github.com/powerman/go-monolith-example" @@ -7,8 +35,10 @@ WORKDIR /app HEALTHCHECK --interval=30s --timeout=5s \ CMD wget -q -O - http://$HOSTNAME:17000/health-check || exit 1 -COPY . . +COPY --from=build "/app/bin/mono" "mono" +COPY --from=build "/app/ms/auth/internal/migrations" "ms/auth/internal/migrations" +COPY --from=build "/app/ms/example/internal/migrations" "ms/example/internal/migrations" -ENTRYPOINT [ "bin/mono" ] +ENTRYPOINT [ "/app/mono" ] CMD [ "serve" ] diff --git a/README.md b/README.md index 1f1d52e..403da62 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,29 @@ for more details. - Go 1.16 - [Docker](https://docs.docker.com/install/) 19.03+ - [Docker Compose](https://docs.docker.com/compose/install/) 1.25+ +- [Buf Build](https://buf.build/docs/installation) 1.1.0 +- [protoc-gen-go v1.26.0-devel]() 1.26+ +- [Api Linter](https://linter.aip.dev/) 1.58+ +- [Shellcheck](https://www.shellcheck.net/) 0.9+ +- [golangci-lint](https://golangci-lint.run/) 1.55+ ### Setup +Install gRPC libraries +```shell +go install github.com/bufbuild/buf/cmd/buf@v1.1.0 + +go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway \ + github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 \ + google.golang.org/protobuf/cmd/protoc-gen-go \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc +``` + +Instsll API Linter +```shell +go install github.com/googleapis/api-linter/cmd/api-linter@latest +``` + 1. After cloning the repo copy `env.sh.dist` to `env.sh`. 2. Review `env.sh` and update for your system as needed. 3. It's recommended to add shell alias `alias dc="if test -f env.sh; then @@ -129,7 +149,7 @@ for more details. and also it uses gRPC with authentication which also require TLS certs, so you'll need to create certificate to run it on localhost - follow instructions in [Create local CA to issue localhost HTTPS - certificates](https://gist.github.com/powerman/2fc4b1a5aee62dd9491cee7f75ead0b4). + certificates](./docs/ca-certificate.md). 2. Or you can just use certificates in `configs/insecure-dev-pki`, which was created this way: diff --git a/api/proto/powerman/example/auth/service.proto b/api/proto/powerman/example/auth/service.proto index 4af8bea..166a154 100644 --- a/api/proto/powerman/example/auth/service.proto +++ b/api/proto/powerman/example/auth/service.proto @@ -1,5 +1,8 @@ syntax = "proto3"; +// (-- api-linter: core::0191::proto-package=disabled +// aip.dev/not-precedent: The parent path is api/proto --) + package powerman.example.auth; import "google/api/annotations.proto"; @@ -55,7 +58,7 @@ message CreateAccountRequest { Account account = 1 [(google.api.field_behavior) = REQUIRED]; // The ID to use for the account. // This value should be 4-63 characters [a-z0-9-]. - string account_id = 2; + string account_id = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; } // Request. @@ -63,38 +66,38 @@ message SigninIdentityRequest { // Authentication using username and password. message AccountAuth { // This value should be 4-63 characters [a-z0-9-]. - string account_id = 1; + string account_id = 1 [(google.api.field_behavior) = REQUIRED]; // Any value. - string password = 2; + string password = 2 [(google.api.field_behavior) = REQUIRED]; } // Authentication user email and password. message EmailAuth { // This value should contain [@]. - string email = 1; + string email = 1 [(google.api.field_behavior) = REQUIRED]; // Any value. - string password = 2; + string password = 2 [(google.api.field_behavior) = REQUIRED]; } // Different ways to authenticate. oneof auth { // By username. - AccountAuth account = 1; + AccountAuth account = 1 [(google.api.field_behavior) = REQUIRED]; // By email. - EmailAuth email = 2; + EmailAuth email = 2 [(google.api.field_behavior) = REQUIRED]; } } // Response. message SigninIdentityResponse { // Opaque. - string access_token = 1; + string access_token = 1 [(google.api.field_behavior) = REQUIRED]; // User/Access details. - User user = 2; + User user = 2 [(google.api.field_behavior) = REQUIRED]; } // Request. message SignoutIdentityRequest { // Set to true to invalidate all user's access_token. - bool everywhere = 1; + bool everywhere = 1 [(google.api.field_behavior) = REQUIRED]; } // Response. @@ -103,13 +106,13 @@ message SignoutIdentityResponse {} // Account contains data needed for authentication. message Account { // Format: "accounts/{account_id}". - string name = 1; + string name = 1 [(google.api.field_behavior) = REQUIRED]; // Default identity connected to the account. - User user = 2; + User user = 2 [(google.api.field_behavior) = REQUIRED]; // Must be strong enough. - string password = 16 [(google.api.field_behavior) = INPUT_ONLY]; + string password = 16 [(google.api.field_behavior) = OPTIONAL]; // Primary email, needed to reset password. - string email = 3; + string email = 3 [(google.api.field_behavior) = REQUIRED]; // Account create time. // Output only. google.protobuf.Timestamp create_time = 15 [(google.api.field_behavior) = OUTPUT_ONLY]; @@ -118,11 +121,11 @@ message Account { // User is an identity tied to Account. message User { // Format: "users/{user_uid}". - string name = 1; + string name = 1 [(google.api.field_behavior) = REQUIRED]; // By default set to {account_id}. - string display_name = 2; + string display_name = 2 [(google.api.field_behavior) = REQUIRED]; // Permissions. - Access access = 3; + Access access = 3 [(google.api.field_behavior) = REQUIRED]; } // Access describes identity's permissions. @@ -139,5 +142,5 @@ message Access { ROLE_USER = 2; } // User's role. - Role role = 1; + Role role = 1 [(google.api.field_behavior) = REQUIRED]; } diff --git a/api/proto/powerman/example/auth/service_int.proto b/api/proto/powerman/example/auth/service_int.proto index f6876ce..09a6aea 100644 --- a/api/proto/powerman/example/auth/service_int.proto +++ b/api/proto/powerman/example/auth/service_int.proto @@ -2,6 +2,8 @@ syntax = "proto3"; // (-- api-linter: core::0127::http-annotation=disabled // aip.dev/not-precedent: No HTTP support for private API. --) +// (-- api-linter: core::0191::proto-package=disabled +// aip.dev/not-precedent: The parent path is api/proto --) package powerman.example.auth; diff --git a/docker-compose.yml b/docker-compose.yml index d6c9ac9..db4e658 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ volumes: services: mysql: + platform: "linux/amd64" image: "mysql:5.7" # We're using 5.7. container_name: "mono_mysql" restart: "always" @@ -98,7 +99,9 @@ services: mono: build: context: . - dockerfile: use `./scripts/build` instead of `docker-compose build` + args: + MONO_VERSION: ${MONO_VERSION:-latest} + dockerfile: Dockerfile image: "go-monolith-example:latest" container_name: mono_mono restart: always diff --git a/docs/ca-certificate.md b/docs/ca-certificate.md new file mode 100644 index 0000000..802dd52 --- /dev/null +++ b/docs/ca-certificate.md @@ -0,0 +1,43 @@ +# Create local CA to issue localhost HTTPS certificates + +You can check [How to securely test local/staging HTTPS +project](securely-test-local.md) +for more details about required setup or just follow instructions below. + +**WARNING:** You'll need to run these commands just once, don't run them +again if you already did this before for some other project. + +MacOS users should first prepare OpenSSL package: +``` +brew install openssl +export EASYRSA_OPENSSL="$(ls -1 $(brew --prefix)/bin/openssl | sort -n -t/ -k6 | tail -n1)" +``` + +Install EasyRSA into `~/.easyrsa/` to generate local CA and website +certificates: +``` +mkdir -p ~/.easyrsa && + curl -L https://github.com/OpenVPN/easy-rsa/releases/download/v3.1.6/EasyRSA-3.1.6.tgz | + tar xzvf - --strip-components=1 -C ~/.easyrsa +``` + +Create local CA for signing certificates for local websites plus +Diffie-Hellman parameter for DHE cipher suites: +``` +cd ~/.easyrsa +./easyrsa init-pki +echo Local CA $(hostname -f) | ./easyrsa build-ca nopass +openssl dhparam 2048 | install -m 0600 /dev/stdin pki/private/dhparam2048.pem +``` + +Now import local CA certificate `~/.easyrsa/pki/ca.crt` into your browser: + +- MacOS: You can easily add the certificate as a trusted certificate + authority for the currently logged in user: + `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.easyrsa/pki/ca.crt` +- Linux: + - Chrome-based browsers: go to chrome://settings/certificates, + AUTHORITIES, IMPORT, select file, check "Trust this certificate for + identifying websites", OK. + - Firefox, command-line tools (curl, etc.): + `sudo mkdir -p /usr/local/share/ca-certificates && sudo cp ~/.easyrsa/pki/ca.crt /usr/local/share/ca-certificates/ && sudo chmod 0644 /usr/local/share/ca-certificates/ca.crt && sudo update-ca-certificates` diff --git a/docs/securely-test-local.md b/docs/securely-test-local.md new file mode 100644 index 0000000..a0443b3 --- /dev/null +++ b/docs/securely-test-local.md @@ -0,0 +1,116 @@ +# How to securely test local/staging HTTPS project + +Modern projects often support HTTPS and HTTP/2, moreover they can use `Strict-Transport-Security:` and +`Content-Security-Policy:` headers which result in different behaviour for HTTP and HTTPS versions, or +even completely forbid HTTP version. To develop and test such project locally, on CI, and at staging server +we either have to provide a way to access it using HTTP in non-production environments (bad idea) or +somehow make it work with HTTPS everywhere. + +> HTTP in non-production environments is a bad idea because we'll test not the same thing which will runs +on production, and because there is a chance to occasionally keep HTTP enabled on production too. + +Quick and dirty way to provide HTTPS everywhere is to either create self-signed certificate or company's +own CA and use it to sign certificate for a project, and then include this certificate in a project's repo. +This also doesn't work well because self-signed certificates are very inconvenient to use (endless browser +warnings for everyone), while company's CA has to be installed into and trusted by every employee's browser, +which opens possibility to use that CA to issue certificate for any website and use MitM attack on any +employee to analyse/modify all his traffic. This became even worse in case some developers/testers are freelancers, +who works on projects for many different companies, and for sure won't like to install each company's CA +into browser on their own workstation. + +So, let's do it right way: +- Each developer/tester who wants to run project **locally will use his own CA**. +- CI will use it's own CA. +- **Staging will runs on public domain with real certificate** (e.g., using free Let's Encrypt). + +## How to get certificate to run project locally + +I'll use `~/.easyrsa/` here, but feel free to change it to whatever you like to. + +### Once, on each developer's/tester's workstation + +#### Install EasyRSA tool +This command may need GNU tar on OSX, or you can unpack archive in any other way you like. +```sh +mkdir -p ~/.easyrsa && + curl -L https://github.com/OpenVPN/easy-rsa/releases/download/v3.1.2/EasyRSA-3.1.2.tgz | + tar xzvf - --strip-components=1 -C ~/.easyrsa +``` +Same command can be used to upgrade EasyRSA later (just change version in url). + +#### Create your CA +```sh +cd ~/.easyrsa +./easyrsa init-pki +echo Local CA $(hostname -f) | ./easyrsa build-ca nopass +``` +If you like to password-protect your CA to ensure it won't be used to issue certificates (trusted by +your browser) if someone manage to steal it from your workstaion, then remove `nopass`. + +Now import `~/.easyrsa/pki/ca.crt` into your browser's CA list. + +#### Create certificate for localhost +This is optional, but local projects often run on https://localhost:projectport/, and they all may +use this certificate, so let's create it, just in case. +```sh +./easyrsa --days=3650 "--subject-alt-name=IP:127.0.0.1,DNS:localhost,DNS:*.localhost" build-server-full localhost nopass +``` +I've included `*.localhost` to let you add something like `www.localhost` to your `/etc/hosts` and +run project on https://www.localhost instead of https://localhost - this may be important because +browsers handle cookies differently for domains with/without dots in some cases. +**Update:** Looks like this doesn't work for `*.localhost` for some reason (at least in Chromium - probably +it handle it like `*.com` and reject as too unsafe), but it does work for `*.project.localhost`. + +### For each project running on localhost +```sh +cp ~/.easyrsa/pki/issued/localhost.crt /path/to/project.crt +cp ~/.easyrsa/pki/private/localhost.key /path/to/project.key +``` +If you use docker to run project then you can bind-mount certificate instead of copying it: +```sh +docker run --name nginx -d -p 8080:80 \ + -v /path/to/your/nginx/conf.d:/etc/nginx/conf.d:ro \ + -v ~/.easyrsa/pki/issued/localhost.crt:/etc/nginx/ssl/server.crt:ro \ + -v ~/.easyrsa/pki/private/localhost.key:/etc/nginx/ssl/server.key:ro \ + nginx:alpine +``` + +### For each project running on unique local domain +Replace `project.home.arpa` with your local domain used to run this project. +```sh +cd ~/.easyrsa +./easyrsa build-server-full project.home.arpa nopass +cp ~/.easyrsa/pki/issued/project.home.arpa.crt /path/to/project.crt +cp ~/.easyrsa/pki/private/project.home.arpa.key /path/to/project.key +``` +Now you may need to add project.home.arpa to your `/etc/hosts` or local DNS. +You can use this to provide access to local project for another devices in your LAN (like smartphone). +If you'll want to use your LAN's IP 192.168.0.42 instead of domain (editing `/etc/hosts` on smartphone +may not be easy, and running local DNS too) then create certificate this way: +```sh +./easyrsa --days=3650 "--subject-alt-name=IP:192.168.0.42,DNS:project.home.arpa" build-server-full project.home.arpa nopass +``` + +## How to setup CI + +Create CA and certificate for project in same way as local. +(Either create them each time you run build in CI, or create just once and set them as CI's environment variables.) + +Next, you'll have to make your `~/.easyrsa/pki/ca.crt` trusted by OS. How this should be done depends on your +linux distributive (e.g., on Ubuntu you'll need to `cp ca.crt /usr/local/share/ca-certificates/ca.crt && update-ca-certificates`). + +## How to restrict access to staging environment + +There is no easy way to hide staging website's domain name and get real certificate for it, so just deal with it. + +It is possible to get wildcard certificate for public website https://example.com and use it on +https://staging.example.com, which resolve to internal IP in company's LAN and thus restrict public access. +This also isn't the best solution, because you'll have to copy very powerful wildcard certificate for your +main website to less secure staging server, and because you won't be able to provide access to staging from +the internet if you'll need this in the future. + +Best option is to run staging on public domain and real public IP, accessible from the internet, and then +restrict access using your webserver (e.g., nginx) configuration. This way you can keep access to path +`http://staging.example.com/.well-known/acme-challenge/` (used by Let's Encrypt to issue certificates) open +from the internet, but allow (e.g., by IP or using HTTP Basic auth) access to the rest of staging website to +your employees only. diff --git a/scripts/container-image b/scripts/container-image new file mode 100755 index 0000000..9c4295e --- /dev/null +++ b/scripts/container-image @@ -0,0 +1,7 @@ +#!/bin/bash + +MONO_VERSION="$(git log -1 --format='%h')" +IMAGE_VERSION="${MONO_VERSION}" +IMAGE_NAME="$(basename "$(go list -m)")" + +docker build --build-arg "MONO_VERSION=${MONO_VERSION}" -t "${IMAGE_NAME}:${IMAGE_VERSION}" . \ No newline at end of file