From 71e007de75a8b997bc3dfe1b855309123f930cf4 Mon Sep 17 00:00:00 2001 From: Sandy Maguire Date: Tue, 27 Dec 2022 01:48:30 -0800 Subject: [PATCH 01/33] Pin polysemy to 1.8.0.0 (#2949) * chore: update hackage pins to use new polysemy * chore: also pin kind-generics * chore: changelog --- changelog.d/5-internal/pr-2949 | 1 + nix/haskell-pins.nix | 55 +++++++++++++--------------------- 2 files changed, 21 insertions(+), 35 deletions(-) create mode 100644 changelog.d/5-internal/pr-2949 diff --git a/changelog.d/5-internal/pr-2949 b/changelog.d/5-internal/pr-2949 new file mode 100644 index 0000000000..96f68ed183 --- /dev/null +++ b/changelog.d/5-internal/pr-2949 @@ -0,0 +1 @@ +Update nix pins to point at polysemy-1.8.0.0 diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 0082da9262..b228bb1688 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -203,41 +203,6 @@ let tasty-hunit = "hunit"; }; }; - polysemy = { - src = fetchgit { - url = "https://github.com/polysemy-research/polysemy.git"; - rev = "3855786e58bf397ca8204f3a79d19c24485dabd4"; - sha256 = "sha256-4ans30VWuSMC9HNFb6FWQyc30oxJd2dmFrMGu5/dLg0="; - }; - }; - polysemy-plugin = { - src = fetchgit { - url = "https://github.com/polysemy-research/polysemy.git"; - rev = "3855786e58bf397ca8204f3a79d19c24485dabd4"; - sha256 = "sha256-4ans30VWuSMC9HNFb6FWQyc30oxJd2dmFrMGu5/dLg0="; - }; - packages = { - polysemy-plugin = "polysemy-plugin"; - }; - }; - polysemy-check = { - src = fetchgit { - url = "https://github.com/polysemy-research/polysemy-check.git"; - rev = "4c0d3ff929ae22ae68d962f7f3f7056f357bf7ac"; - sha256 = "sha256-8XeCeJWbkdqrUf6tERFMoGM8xRI5l/nKNqI810kzMs0="; - }; - }; - kind-generics = { - src = fetchgit { - url = "https://gitlab.com/trupill/kind-generics.git"; - rev = "f4ad2bcfacc9c3dcecf64c069d086926465cab2c"; - sha256 = "sha256-uvQMV8aTNyTN+ozrseohexbCneVPMO35Jf1eEhLPk78="; - }; - packages = { - kind-generics = "kind-generics"; - kind-generics-th = "kind-generics-th"; - }; - }; # This can be removed once postie 0.6.0.3 (or later) is in nixpkgs postie = { src = fetchgit { @@ -256,6 +221,26 @@ let version = "0.2.2.1"; sha256 = "sha256-TdsLB0ueaUUllLdvcGu3YNQXCfGRRk5WxP3deHEbHGI="; }; + kind-generics = { + version = "0.4.1.2"; + sha256 = "sha256-orDfC5+QXRlAMVaqAhT1Fo7Eh/AnobROWeliZqEAXZU="; + }; + kind-generics-th = { + version = "0.2.2.2"; + sha256 = "sha256-nPuRq19UGVXe4YrITAZcF+U4TUBo5APMT2Nh9NqIkxQ="; + }; + polysemy = { + version = "1.8.0.0"; + sha256 = "sha256-AdxxKWXdUjZiHLDj6iswMWpycs7mFB8eKhBR4ljF6kk="; + }; + polysemy-check = { + version = "0.9.0.1"; + sha256 = "sha256-CsL2vMxAmpvVVR/iUnZAkbcRLiy/a8ulJQ6QwtCYmRM="; + }; + polysemy-plugin = { + version = "0.4.3.1"; + sha256 = "sha256-0vkLYNZISr3fmmQvD8qdLkn2GHc80l1GzJuOmqjqXE4="; + }; singletons = { version = "2.7"; sha256 = "sha256-q7yc/wyGSyYI0KdgHgRi0WISv9WEibxQ5yM7cSjXS2s="; From a46f5bd8d0107ad92aa6cbf9efa5d8e33a0aa6ab Mon Sep 17 00:00:00 2001 From: Sandy Maguire Date: Wed, 28 Dec 2022 01:48:33 -0800 Subject: [PATCH 02/33] Track federated calls (#2940) * feat: track federation api calls * chore: make format * fix: give a default instance for other packages * feat: galley callsfed tracking * chore: make format * fix: cargohold * chore: make format * doc: changelog.d * chore: remove spurious HasCallStack * doc: changelog.d --- changelog.d/5-internal/federated-calls-brig | 1 + changelog.d/5-internal/pr-2940 | 1 + .../src/Wire/API/Federation/API.hs | 7 +- services/brig/src/Brig/API.hs | 23 +- services/brig/src/Brig/API/Auth.hs | 10 +- services/brig/src/Brig/API/Client.hs | 25 +- services/brig/src/Brig/API/Connection.hs | 4 +- .../brig/src/Brig/API/Connection/Remote.hs | 4 + services/brig/src/Brig/API/Internal.hs | 117 +-- services/brig/src/Brig/API/MLS/KeyPackages.hs | 2 + services/brig/src/Brig/API/Public.hs | 207 +++-- services/brig/src/Brig/API/User.hs | 91 +- services/brig/src/Brig/Federation/Client.hs | 16 +- services/brig/src/Brig/IO/Intra.hs | 11 +- .../brig/src/Brig/InternalEvent/Process.hs | 4 +- services/brig/src/Brig/Run.hs | 6 + services/brig/src/Brig/Team/API.hs | 25 +- services/brig/src/Brig/User/API/Handle.hs | 7 +- services/brig/src/Brig/User/API/Search.hs | 7 +- services/brig/src/Brig/User/Auth.hs | 17 +- .../cargohold/src/CargoHold/API/Public.hs | 4 +- .../cargohold/src/CargoHold/Federation.hs | 1 + services/cargohold/src/CargoHold/Run.hs | 4 + .../test/unit/Test/Federator/Client.hs | 3 + services/galley/src/Galley/API/Action.hs | 40 +- services/galley/src/Galley/API/Clients.hs | 4 +- services/galley/src/Galley/API/Create.hs | 201 +++-- services/galley/src/Galley/API/Federation.hs | 139 +-- services/galley/src/Galley/API/Internal.hs | 3 + services/galley/src/Galley/API/LegalHold.hs | 393 +++++---- .../galley/src/Galley/API/MLS/GroupInfo.hs | 20 +- services/galley/src/Galley/API/MLS/Message.hs | 130 ++- .../galley/src/Galley/API/MLS/Propagate.hs | 3 +- services/galley/src/Galley/API/MLS/Removal.hs | 13 +- services/galley/src/Galley/API/MLS/Welcome.hs | 56 +- services/galley/src/Galley/API/Message.hs | 42 +- services/galley/src/Galley/API/Public/Bot.hs | 7 +- .../src/Galley/API/Public/Conversation.hs | 14 +- .../galley/src/Galley/API/Public/Feature.hs | 8 +- .../galley/src/Galley/API/Public/LegalHold.hs | 8 +- services/galley/src/Galley/API/Public/MLS.hs | 12 +- .../galley/src/Galley/API/Public/Messaging.hs | 8 +- .../galley/src/Galley/API/Public/Servant.hs | 21 +- .../src/Galley/API/Public/TeamConversation.hs | 8 +- services/galley/src/Galley/API/Query.hs | 46 +- services/galley/src/Galley/API/Teams.hs | 39 +- .../galley/src/Galley/API/Teams/Features.hs | 9 +- services/galley/src/Galley/API/Update.hs | 825 ++++++++++-------- services/galley/src/Galley/API/Util.hs | 2 +- 49 files changed, 1579 insertions(+), 1069 deletions(-) create mode 100644 changelog.d/5-internal/federated-calls-brig create mode 100644 changelog.d/5-internal/pr-2940 diff --git a/changelog.d/5-internal/federated-calls-brig b/changelog.d/5-internal/federated-calls-brig new file mode 100644 index 0000000000..e923838886 --- /dev/null +++ b/changelog.d/5-internal/federated-calls-brig @@ -0,0 +1 @@ +Added typeclasses to track uses of federated calls across the codebase. diff --git a/changelog.d/5-internal/pr-2940 b/changelog.d/5-internal/pr-2940 new file mode 100644 index 0000000000..90ec15d754 --- /dev/null +++ b/changelog.d/5-internal/pr-2940 @@ -0,0 +1 @@ +Track federated calls in types across the codebase. diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API.hs b/libs/wire-api-federation/src/Wire/API/Federation/API.hs index 7d55f99152..703fdc8dfa 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API.hs @@ -20,6 +20,7 @@ module Wire.API.Federation.API HasFedEndpoint, fedClient, fedClientIn, + CallsFed, -- * Re-exports Component (..), @@ -48,12 +49,14 @@ type instance FedApi 'Brig = BrigApi type instance FedApi 'Cargohold = CargoholdApi -type HasFedEndpoint comp api name = ('Just api ~ LookupEndpoint (FedApi comp) name) +type HasFedEndpoint comp api name = ('Just api ~ LookupEndpoint (FedApi comp) name, CallsFed comp name) + +class CallsFed (comp :: Component) (name :: Symbol) -- | Return a client for a named endpoint. fedClient :: forall (comp :: Component) (name :: Symbol) m api. - (HasFedEndpoint comp api name, HasClient m api, m ~ FederatorClient comp) => + (CallsFed comp name, HasFedEndpoint comp api name, HasClient m api, m ~ FederatorClient comp) => Client m api fedClient = clientIn (Proxy @api) (Proxy @m) diff --git a/services/brig/src/Brig/API.hs b/services/brig/src/Brig/API.hs index f5cef37677..0ec53a3734 100644 --- a/services/brig/src/Brig/API.hs +++ b/services/brig/src/Brig/API.hs @@ -32,20 +32,23 @@ import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import qualified Data.Swagger.Build.Api as Doc import Network.Wai.Routing (Routes) import Polysemy +import Wire.API.Federation.API import Wire.Sem.Concurrency sitemap :: forall r p. - Members - '[ BlacklistPhonePrefixStore, - BlacklistStore, - GalleyProvider, - CodeStore, - Concurrency 'Unsafe, - PasswordResetStore, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistPhonePrefixStore, + BlacklistStore, + GalleyProvider, + CodeStore, + Concurrency 'Unsafe, + PasswordResetStore, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Routes Doc.ApiBuilder (Handler r) () sitemap = do Public.sitemap diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index cce5a9e335..b89733053e 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -43,6 +43,7 @@ import Network.HTTP.Types import Network.Wai.Utilities ((!>>)) import qualified Network.Wai.Utilities.Error as Wai import Polysemy +import Wire.API.Federation.API import Wire.API.User import Wire.API.User.Auth hiding (access) import Wire.API.User.Auth.LegalHold @@ -50,6 +51,7 @@ import Wire.API.User.Auth.ReAuth import Wire.API.User.Auth.Sso accessH :: + CallsFed 'Brig "on-user-deleted-connections" => Maybe ClientId -> [Either Text SomeUserToken] -> Maybe (Either Text SomeAccessToken) -> @@ -61,7 +63,7 @@ accessH mcid ut' mat' = do >>= either (uncurry (access mcid)) (uncurry (access mcid)) access :: - TokenPair u a => + (TokenPair u a, CallsFed 'Brig "on-user-deleted-connections") => Maybe ClientId -> NonEmpty (Token u) -> Maybe (Token a) -> @@ -76,7 +78,7 @@ sendLoginCode (SendLoginCode phone call force) = do c <- wrapClientE (Auth.sendLoginCode phone call force) !>> sendLoginCodeError pure $ LoginCodeTimeout (pendingLoginTimeout c) -login :: Member GalleyProvider r => Login -> Maybe Bool -> Handler r SomeAccess +login :: (Member GalleyProvider r, CallsFed 'Brig "on-user-deleted-connections") => Login -> Maybe Bool -> Handler r SomeAccess login l (fromMaybe False -> persist) = do let typ = if persist then PersistentCookie else SessionCookie c <- Auth.login l typ !>> loginError @@ -128,13 +130,13 @@ removeCookies :: Local UserId -> RemoveCookies -> Handler r () removeCookies lusr (RemoveCookies pw lls ids) = wrapClientE (Auth.revokeAccess (tUnqualified lusr) pw ids lls) !>> authError -legalHoldLogin :: Member GalleyProvider r => LegalHoldLogin -> Handler r SomeAccess +legalHoldLogin :: (Member GalleyProvider r, CallsFed 'Brig "on-user-deleted-connections") => LegalHoldLogin -> Handler r SomeAccess legalHoldLogin lhl = do let typ = PersistentCookie -- Session cookie isn't a supported use case here c <- Auth.legalHoldLogin lhl typ !>> legalHoldLoginError traverse mkUserTokenCookie c -ssoLogin :: SsoLogin -> Maybe Bool -> Handler r SomeAccess +ssoLogin :: CallsFed 'Brig "on-user-deleted-connections" => SsoLogin -> Maybe Bool -> Handler r SomeAccess ssoLogin l (fromMaybe False -> persist) = do let typ = if persist then PersistentCookie else SessionCookie c <- wrapHttpClientE (Auth.ssoLogin l typ) !>> loginError diff --git a/services/brig/src/Brig/API/Client.hs b/services/brig/src/Brig/API/Client.hs index 5ba34937e8..4c30599850 100644 --- a/services/brig/src/Brig/API/Client.hs +++ b/services/brig/src/Brig/API/Client.hs @@ -93,6 +93,7 @@ import Polysemy (Member, Members) import Servant (Link, ToHttpApiData (toUrlPiece)) import System.Logger.Class (field, msg, val, (~~)) import qualified System.Logger.Class as Log +import Wire.API.Federation.API import Wire.API.Federation.API.Brig (GetUserClients (GetUserClients)) import Wire.API.Federation.Error import Wire.API.MLS.Credential (ClientIdentity (..)) @@ -115,12 +116,12 @@ lookupLocalClient uid = wrapClient . Data.lookupClient uid lookupLocalClients :: UserId -> (AppT r) [Client] lookupLocalClients = wrapClient . Data.lookupClients -lookupPubClient :: Qualified UserId -> ClientId -> ExceptT ClientError (AppT r) (Maybe PubClient) +lookupPubClient :: CallsFed 'Brig "get-user-clients" => Qualified UserId -> ClientId -> ExceptT ClientError (AppT r) (Maybe PubClient) lookupPubClient qid cid = do clients <- lookupPubClients qid pure $ find ((== cid) . pubClientId) clients -lookupPubClients :: Qualified UserId -> ExceptT ClientError (AppT r) [PubClient] +lookupPubClients :: CallsFed 'Brig "get-user-clients" => Qualified UserId -> ExceptT ClientError (AppT r) [PubClient] lookupPubClients qid@(Qualified uid domain) = do getForUser <$> lookupPubClientsBulk [qid] where @@ -129,7 +130,7 @@ lookupPubClients qid@(Qualified uid domain) = do um <- userMap <$> Map.lookup domain (qualifiedUserMap qmap) Set.toList <$> Map.lookup uid um -lookupPubClientsBulk :: [Qualified UserId] -> ExceptT ClientError (AppT r) (QualifiedUserMap (Set PubClient)) +lookupPubClientsBulk :: CallsFed 'Brig "get-user-clients" => [Qualified UserId] -> ExceptT ClientError (AppT r) (QualifiedUserMap (Set PubClient)) lookupPubClientsBulk qualifiedUids = do loc <- qualifyLocal () let (localUsers, remoteUsers) = partitionQualified loc qualifiedUids @@ -145,7 +146,7 @@ lookupLocalPubClientsBulk :: [UserId] -> ExceptT ClientError (AppT r) (UserMap ( lookupLocalPubClientsBulk = lift . wrapClient . Data.lookupPubClientsBulk addClient :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => UserId -> Maybe ConnId -> Maybe IP -> @@ -157,7 +158,7 @@ addClient = addClientWithReAuthPolicy Data.reAuthForNewClients -- a superset of the clients known to galley. addClientWithReAuthPolicy :: forall r. - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => Data.ReAuthPolicy -> UserId -> Maybe ConnId -> @@ -238,6 +239,7 @@ rmClient u con clt pw = lift $ execDelete u (Just con) client claimPrekey :: + CallsFed 'Brig "claim-prekey" => LegalholdProtectee -> UserId -> Domain -> @@ -264,14 +266,15 @@ claimLocalPrekey protectee user client = do claimRemotePrekey :: ( MonadReader Env m, Log.MonadLogger m, - MonadClient m + MonadClient m, + CallsFed 'Brig "claim-prekey" ) => Qualified UserId -> ClientId -> ExceptT ClientError m (Maybe ClientPrekey) claimRemotePrekey quser client = fmapLT ClientFederationError $ Federation.claimPrekey quser client -claimPrekeyBundle :: LegalholdProtectee -> Domain -> UserId -> ExceptT ClientError (AppT r) PrekeyBundle +claimPrekeyBundle :: CallsFed 'Brig "claim-prekey-bundle" => LegalholdProtectee -> Domain -> UserId -> ExceptT ClientError (AppT r) PrekeyBundle claimPrekeyBundle protectee domain uid = do isLocalDomain <- (domain ==) <$> viewFederationDomain if isLocalDomain @@ -284,13 +287,13 @@ claimLocalPrekeyBundle protectee u = do guardLegalhold protectee (mkUserClients [(u, clients)]) PrekeyBundle u . catMaybes <$> lift (mapM (wrapHttp . Data.claimPrekey u) clients) -claimRemotePrekeyBundle :: Qualified UserId -> ExceptT ClientError (AppT r) PrekeyBundle +claimRemotePrekeyBundle :: CallsFed 'Brig "claim-prekey-bundle" => Qualified UserId -> ExceptT ClientError (AppT r) PrekeyBundle claimRemotePrekeyBundle quser = do Federation.claimPrekeyBundle quser !>> ClientFederationError claimMultiPrekeyBundles :: forall r. - Members '[Concurrency 'Unsafe] r => + (Members '[Concurrency 'Unsafe] r, CallsFed 'Brig "claim-multi-prekey-bundle") => LegalholdProtectee -> QualifiedUserClients -> ExceptT ClientError (AppT r) QualifiedUserClientPrekeyMap @@ -410,7 +413,7 @@ pubClient c = pubClientClass = clientClass c } -legalHoldClientRequested :: UserId -> LegalHoldClientRequest -> (AppT r) () +legalHoldClientRequested :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> LegalHoldClientRequest -> (AppT r) () legalHoldClientRequested targetUser (LegalHoldClientRequest _requester lastPrekey') = wrapHttpClient $ Intra.onUserEvent targetUser Nothing lhClientEvent where @@ -421,7 +424,7 @@ legalHoldClientRequested targetUser (LegalHoldClientRequest _requester lastPreke lhClientEvent :: UserEvent lhClientEvent = LegalHoldClientRequested eventData -removeLegalHoldClient :: UserId -> (AppT r) () +removeLegalHoldClient :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> (AppT r) () removeLegalHoldClient uid = do clients <- wrapClient $ Data.lookupClients uid -- Should only be one; but just in case we'll treat it as a list diff --git a/services/brig/src/Brig/API/Connection.hs b/services/brig/src/Brig/API/Connection.hs index e3ba7798ae..f1c54d08dc 100644 --- a/services/brig/src/Brig/API/Connection.hs +++ b/services/brig/src/Brig/API/Connection.hs @@ -60,6 +60,7 @@ import Wire.API.Connection hiding (relationWithHistory) import Wire.API.Conversation import Wire.API.Error import qualified Wire.API.Error.Brig as E +import Wire.API.Federation.API import Wire.API.Routes.Public.Util (ResponseForExistedCreated (..)) ensureIsActivated :: Local UserId -> MaybeT (AppT r) () @@ -75,7 +76,7 @@ ensureNotSameTeam self target = do throwE ConnectSameBindingTeamUsers createConnection :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "send-connection-action") => Local UserId -> ConnId -> Qualified UserId -> @@ -210,6 +211,7 @@ checkLegalholdPolicyConflict uid1 uid2 = do oneway status2 status1 updateConnection :: + CallsFed 'Brig "send-connection-action" => Local UserId -> Qualified UserId -> Relation -> diff --git a/services/brig/src/Brig/API/Connection/Remote.hs b/services/brig/src/Brig/API/Connection/Remote.hs index 54894fb30f..4567753e68 100644 --- a/services/brig/src/Brig/API/Connection/Remote.hs +++ b/services/brig/src/Brig/API/Connection/Remote.hs @@ -39,6 +39,7 @@ import Galley.Types.Conversations.Intra (Actor (..), DesiredMembership (..), Ups import Imports import Network.Wai.Utilities.Error import Wire.API.Connection +import Wire.API.Federation.API import Wire.API.Federation.API.Brig ( NewConnectionResponse (..), RemoteConnectionAction (..), @@ -187,6 +188,7 @@ pushEvent self mzcon connection = do Intra.onConnectionEvent (tUnqualified self) mzcon event performLocalAction :: + CallsFed 'Brig "send-connection-action" => Local UserId -> Maybe ConnId -> Remote UserId -> @@ -251,6 +253,7 @@ performRemoteAction self other mconnection action = do reaction _ = Nothing createConnectionToRemoteUser :: + CallsFed 'Brig "send-connection-action" => Local UserId -> ConnId -> Remote UserId -> @@ -260,6 +263,7 @@ createConnectionToRemoteUser self zcon other = do fst <$> performLocalAction self (Just zcon) other mconnection LocalConnect updateConnectionToRemoteUser :: + CallsFed 'Brig "send-connection-action" => Local UserId -> Remote UserId -> Relation -> diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index bb0c7f75bc..9c6ea8ed16 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -87,6 +87,7 @@ import UnliftIO.Async import Wire.API.Connection import Wire.API.Error import qualified Wire.API.Error.Brig as E +import Wire.API.Federation.API import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage import Wire.API.MLS.Serialisation @@ -105,12 +106,14 @@ import Wire.API.User.RichInfo -- Sitemap (servant) servantSitemap :: - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = ejpdAPI @@ -150,12 +153,14 @@ mlsAPI = :<|> Named @"put-key-package-add" upsertKeyPackage accountAPI :: - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => ServerT BrigIRoutes.AccountAPI (Handler r) accountAPI = Named @"createUserNoVerify" createUserNoVerify @@ -170,7 +175,7 @@ userAPI = :<|> deleteLocale :<|> getDefaultUserLocale -authAPI :: Member GalleyProvider r => ServerT BrigIRoutes.AuthAPI (Handler r) +authAPI :: (Member GalleyProvider r, CallsFed 'Brig "on-user-deleted-connections") => ServerT BrigIRoutes.AuthAPI (Handler r) authAPI = Named @"legalhold-login" legalHoldLogin :<|> Named @"sso-login" ssoLogin @@ -284,15 +289,17 @@ swaggerDocsAPI = swaggerSchemaUIServer BrigIRoutes.swaggerDoc -- Sitemap (wai-route) sitemap :: - Members - '[ CodeStore, - PasswordResetStore, - BlacklistStore, - BlacklistPhonePrefixStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ CodeStore, + PasswordResetStore, + BlacklistStore, + BlacklistPhonePrefixStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Routes a (Handler r) () sitemap = do get "/i/status" (continue $ const $ pure empty) true @@ -456,10 +463,12 @@ sitemap = do -- | Add a client without authentication checks addClientInternalH :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => UserId ::: Maybe Bool ::: JsonRequest NewClient ::: Maybe ConnId ::: JSON -> (Handler r) Response addClientInternalH (usr ::: mSkipReAuth ::: req ::: connId ::: _) = do @@ -467,10 +476,12 @@ addClientInternalH (usr ::: mSkipReAuth ::: req ::: connId ::: _) = do setStatus status201 . json <$> addClientInternal usr mSkipReAuth new connId addClientInternal :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => UserId -> Maybe Bool -> NewClient -> @@ -482,13 +493,13 @@ addClientInternal usr mSkipReAuth new connId = do | otherwise = Data.reAuthForNewClients API.addClientWithReAuthPolicy policy usr connId Nothing new !>> clientError -legalHoldClientRequestedH :: UserId ::: JsonRequest LegalHoldClientRequest ::: JSON -> (Handler r) Response +legalHoldClientRequestedH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JsonRequest LegalHoldClientRequest ::: JSON -> (Handler r) Response legalHoldClientRequestedH (targetUser ::: req ::: _) = do clientRequest <- parseJsonBody req lift $ API.legalHoldClientRequested targetUser clientRequest pure $ setStatus status200 empty -removeLegalHoldClientH :: UserId ::: JSON -> (Handler r) Response +removeLegalHoldClientH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JSON -> (Handler r) Response removeLegalHoldClientH (uid ::: _) = do lift $ API.removeLegalHoldClient uid pure $ setStatus status200 empty @@ -511,12 +522,14 @@ internalListFullClients (UserSet usrs) = UserClientsFull <$> wrapClient (Data.lookupClientsBulk (Set.toList usrs)) createUserNoVerify :: - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => NewUser -> (Handler r) (Either RegisterError SelfProfile) createUserNoVerify uData = lift . runExceptT $ do @@ -533,10 +546,12 @@ createUserNoVerify uData = lift . runExceptT $ do pure . SelfProfile $ usr createUserNoVerifySpar :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => NewUserSpar -> (Handler r) (Either CreateUserSparError SelfProfile) createUserNoVerifySpar uData = @@ -553,7 +568,7 @@ createUserNoVerifySpar uData = in API.activate key code (Just uid) !>> CreateUserSparRegistrationError . activationErrorToRegisterError pure . SelfProfile $ usr -deleteUserNoAuthH :: UserId -> (Handler r) Response +deleteUserNoAuthH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> (Handler r) Response deleteUserNoAuthH uid = do r <- lift $ wrapHttp $ API.ensureAccountDeleted uid case r of @@ -652,7 +667,7 @@ newtype GetPasswordResetCodeResp = GetPasswordResetCodeResp (PasswordResetKey, P instance ToJSON GetPasswordResetCodeResp where toJSON (GetPasswordResetCodeResp (k, c)) = object ["key" .= k, "code" .= c] -changeAccountStatusH :: UserId ::: JsonRequest AccountStatusUpdate -> (Handler r) Response +changeAccountStatusH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JsonRequest AccountStatusUpdate -> (Handler r) Response changeAccountStatusH (usr ::: req) = do status <- suStatus <$> parseJsonBody req wrapHttpClientE (API.changeSingleAccountStatus usr status) !>> accountStatusError @@ -689,7 +704,7 @@ getConnectionsStatus (ConnectionsStatusRequestV2 froms mtos mrel) = do where filterByRelation l rel = filter ((== rel) . csv2Status) l -revokeIdentityH :: Either Email Phone -> (Handler r) Response +revokeIdentityH :: (CallsFed 'Brig "on-user-deleted-connections") => Either Email Phone -> (Handler r) Response revokeIdentityH emailOrPhone = do lift $ API.revokeIdentity emailOrPhone pure $ setStatus status200 empty @@ -736,7 +751,7 @@ addPhonePrefixH (_ ::: req) = do void . lift $ API.phonePrefixInsert prefix pure empty -updateSSOIdH :: UserId ::: JSON ::: JsonRequest UserSSOId -> (Handler r) Response +updateSSOIdH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JSON ::: JsonRequest UserSSOId -> (Handler r) Response updateSSOIdH (uid ::: _ ::: req) = do ssoid :: UserSSOId <- parseJsonBody req success <- lift $ wrapClient $ Data.updateSSOId uid (Just ssoid) @@ -746,7 +761,7 @@ updateSSOIdH (uid ::: _ ::: req) = do pure empty else pure . setStatus status404 $ plain "User does not exist or has no team." -deleteSSOIdH :: UserId ::: JSON -> (Handler r) Response +deleteSSOIdH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JSON -> (Handler r) Response deleteSSOIdH (uid ::: _) = do success <- lift $ wrapClient $ Data.updateSSOId uid Nothing if success @@ -802,18 +817,18 @@ getRichInfoMulti :: [UserId] -> (Handler r) [(UserId, RichInfo)] getRichInfoMulti uids = lift (wrapClient $ API.lookupRichInfoMultiUsers uids) -updateHandleH :: UserId ::: JSON ::: JsonRequest HandleUpdate -> (Handler r) Response +updateHandleH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JSON ::: JsonRequest HandleUpdate -> (Handler r) Response updateHandleH (uid ::: _ ::: body) = empty <$ (updateHandle uid =<< parseJsonBody body) -updateHandle :: UserId -> HandleUpdate -> (Handler r) () +updateHandle :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> HandleUpdate -> (Handler r) () updateHandle uid (HandleUpdate handleUpd) = do handle <- validateHandle handleUpd API.changeHandle uid Nothing handle API.AllowSCIMUpdates !>> changeHandleError -updateUserNameH :: UserId ::: JSON ::: JsonRequest NameUpdate -> (Handler r) Response +updateUserNameH :: (CallsFed 'Brig "on-user-deleted-connections") => UserId ::: JSON ::: JsonRequest NameUpdate -> (Handler r) Response updateUserNameH (uid ::: _ ::: body) = empty <$ (updateUserName uid =<< parseJsonBody body) -updateUserName :: UserId -> NameUpdate -> (Handler r) () +updateUserName :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> NameUpdate -> (Handler r) () updateUserName uid (NameUpdate nameUpd) = do name <- either (const $ throwStd (errorToWai @'E.InvalidUser)) pure $ mkName nameUpd let uu = diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index 74742fe176..63379c4de8 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -55,6 +55,7 @@ uploadKeyPackages lusr cid (kpuKeyPackages -> kps) = do lift . wrapClient $ Data.insertKeyPackages (tUnqualified lusr) cid kps' claimKeyPackages :: + CallsFed 'Brig "claim-key-packages" => Local UserId -> Qualified UserId -> Maybe ClientId -> @@ -96,6 +97,7 @@ claimLocalKeyPackages qusr skipOwn target = do <$> wrapClientM (Data.claimKeyPackage target c) claimRemoteKeyPackages :: + CallsFed 'Brig "claim-key-packages" => Local UserId -> Remote UserId -> Handler r KeyPackageBundle diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index becf2a175d..264ff7d456 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -111,6 +111,7 @@ import Util.Logging (logFunction, logHandle, logTeam, logUser) import qualified Wire.API.Connection as Public import Wire.API.Error import qualified Wire.API.Error.Brig as E +import Wire.API.Federation.API import qualified Wire.API.Properties as Public import qualified Wire.API.Routes.MultiTablePaging as Public import Wire.API.Routes.Named (Named (Named)) @@ -168,20 +169,31 @@ versionedSwaggerDocsAPI Nothing = versionedSwaggerDocsAPI (Just maxBound) servantSitemap :: forall r p. - Members - '[ BlacklistPhonePrefixStore, - BlacklistStore, - CodeStore, - Concurrency 'Unsafe, - Concurrency 'Unsafe, - GalleyProvider, - JwtTools, - Now, - PasswordResetStore, - PublicKeyBundle, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistPhonePrefixStore, + BlacklistStore, + CodeStore, + Concurrency 'Unsafe, + Concurrency 'Unsafe, + GalleyProvider, + JwtTools, + Now, + PasswordResetStore, + PublicKeyBundle, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "get-user-by-handle", + CallsFed 'Brig "get-users-by-ids", + CallsFed 'Brig "search-users", + CallsFed 'Brig "claim-key-packages", + CallsFed 'Brig "on-user-deleted-connections", + CallsFed 'Brig "claim-multi-prekey-bundle", + CallsFed 'Brig "send-connection-action", + CallsFed 'Brig "claim-prekey", + CallsFed 'Brig "claim-prekey-bundle", + CallsFed 'Brig "get-user-clients" + ) => ServerT BrigAPI (Handler r) servantSitemap = userAPI @@ -426,22 +438,22 @@ listPropertyKeysAndValues u = do keysAndVals <- fmap Map.fromList . lift $ wrapClient (API.lookupPropertyKeysAndValues u) Public.PropertyKeysAndValues <$> traverse parseStoredPropertyValue keysAndVals -getPrekeyUnqualifiedH :: UserId -> UserId -> ClientId -> (Handler r) Public.ClientPrekey +getPrekeyUnqualifiedH :: (CallsFed 'Brig "claim-prekey") => UserId -> UserId -> ClientId -> (Handler r) Public.ClientPrekey getPrekeyUnqualifiedH zusr user client = do domain <- viewFederationDomain getPrekeyH zusr (Qualified user domain) client -getPrekeyH :: UserId -> Qualified UserId -> ClientId -> (Handler r) Public.ClientPrekey +getPrekeyH :: (CallsFed 'Brig "claim-prekey") => UserId -> Qualified UserId -> ClientId -> (Handler r) Public.ClientPrekey getPrekeyH zusr (Qualified user domain) client = do mPrekey <- API.claimPrekey (ProtectedUser zusr) user domain client !>> clientError ifNothing (notFound "prekey not found") mPrekey -getPrekeyBundleUnqualifiedH :: UserId -> UserId -> (Handler r) Public.PrekeyBundle +getPrekeyBundleUnqualifiedH :: (CallsFed 'Brig "claim-prekey-bundle") => UserId -> UserId -> (Handler r) Public.PrekeyBundle getPrekeyBundleUnqualifiedH zusr uid = do domain <- viewFederationDomain API.claimPrekeyBundle (ProtectedUser zusr) domain uid !>> clientError -getPrekeyBundleH :: UserId -> Qualified UserId -> (Handler r) Public.PrekeyBundle +getPrekeyBundleH :: (CallsFed 'Brig "claim-prekey-bundle") => UserId -> Qualified UserId -> (Handler r) Public.PrekeyBundle getPrekeyBundleH zusr (Qualified uid domain) = API.claimPrekeyBundle (ProtectedUser zusr) domain uid !>> clientError @@ -457,7 +469,7 @@ getMultiUserPrekeyBundleUnqualifiedH zusr userClients = do API.claimLocalMultiPrekeyBundles (ProtectedUser zusr) userClients !>> clientError getMultiUserPrekeyBundleH :: - Members '[Concurrency 'Unsafe] r => + (Members '[Concurrency 'Unsafe] r, CallsFed 'Brig "claim-multi-prekey-bundle") => UserId -> Public.QualifiedUserClients -> (Handler r) Public.QualifiedUserClientPrekeyMap @@ -472,10 +484,12 @@ getMultiUserPrekeyBundleH zusr qualUserClients = do API.claimMultiPrekeyBundles (ProtectedUser zusr) qualUserClients !>> clientError addClient :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => UserId -> ConnId -> Maybe IpAddr -> @@ -506,28 +520,28 @@ listClients zusr = getClient :: UserId -> ClientId -> (Handler r) (Maybe Public.Client) getClient zusr clientId = lift $ API.lookupLocalClient zusr clientId -getUserClientsUnqualified :: UserId -> (Handler r) [Public.PubClient] +getUserClientsUnqualified :: (CallsFed 'Brig "get-user-clients") => UserId -> (Handler r) [Public.PubClient] getUserClientsUnqualified uid = do localdomain <- viewFederationDomain API.lookupPubClients (Qualified uid localdomain) !>> clientError -getUserClientsQualified :: Qualified UserId -> (Handler r) [Public.PubClient] +getUserClientsQualified :: (CallsFed 'Brig "get-user-clients") => Qualified UserId -> (Handler r) [Public.PubClient] getUserClientsQualified quid = API.lookupPubClients quid !>> clientError -getUserClientUnqualified :: UserId -> ClientId -> (Handler r) Public.PubClient +getUserClientUnqualified :: (CallsFed 'Brig "get-user-clients") => UserId -> ClientId -> (Handler r) Public.PubClient getUserClientUnqualified uid cid = do localdomain <- viewFederationDomain x <- API.lookupPubClient (Qualified uid localdomain) cid !>> clientError ifNothing (notFound "client not found") x -listClientsBulk :: UserId -> Range 1 MaxUsersForListClientsBulk [Qualified UserId] -> (Handler r) (Public.QualifiedUserMap (Set Public.PubClient)) +listClientsBulk :: (CallsFed 'Brig "get-user-clients") => UserId -> Range 1 MaxUsersForListClientsBulk [Qualified UserId] -> (Handler r) (Public.QualifiedUserMap (Set Public.PubClient)) listClientsBulk _zusr limitedUids = API.lookupPubClientsBulk (fromRange limitedUids) !>> clientError -listClientsBulkV2 :: UserId -> Public.LimitedQualifiedUserIdList MaxUsersForListClientsBulk -> (Handler r) (Public.WrappedQualifiedUserMap (Set Public.PubClient)) +listClientsBulkV2 :: (CallsFed 'Brig "get-user-clients") => UserId -> Public.LimitedQualifiedUserIdList MaxUsersForListClientsBulk -> (Handler r) (Public.WrappedQualifiedUserMap (Set Public.PubClient)) listClientsBulkV2 zusr userIds = Public.Wrapped <$> listClientsBulk zusr (Public.qualifiedUsers userIds) -getUserClientQualified :: Qualified UserId -> ClientId -> (Handler r) Public.PubClient +getUserClientQualified :: (CallsFed 'Brig "get-user-clients") => Qualified UserId -> ClientId -> (Handler r) Public.PubClient getUserClientQualified quid cid = do x <- API.lookupPubClient quid cid !>> clientError ifNothing (notFound "client not found") x @@ -583,12 +597,14 @@ createAccessToken method uid cid proof = do -- | docs/reference/user/registration.md {#RefRegistration} createUser :: - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Public.NewUserPublic -> (Handler r) (Either Public.RegisterError Public.RegisterSuccess) createUser (Public.NewUserPublic new) = lift . runExceptT $ do @@ -665,10 +681,12 @@ getSelf self = >>= ifNothing (errorToWai @'E.UserNotFound) getUserUnqualifiedH :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "get-users-by-ids" + ) => UserId -> UserId -> (Handler r) (Maybe Public.UserProfile) @@ -677,10 +695,12 @@ getUserUnqualifiedH self uid = do getUser self (Qualified uid domain) getUser :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "get-users-by-ids" + ) => UserId -> Qualified UserId -> (Handler r) (Maybe Public.UserProfile) @@ -690,11 +710,13 @@ getUser self qualifiedUserId = do -- FUTUREWORK: Make servant understand that at least one of these is required listUsersByUnqualifiedIdsOrHandles :: - Members - '[ GalleyProvider, - Concurrency 'Unsafe - ] - r => + ( Members + '[ GalleyProvider, + Concurrency 'Unsafe + ] + r, + CallsFed 'Brig "get-users-by-ids" + ) => UserId -> Maybe (CommaSeparatedList UserId) -> Maybe (Range 1 4 (CommaSeparatedList Handle)) -> @@ -716,11 +738,13 @@ listUsersByUnqualifiedIdsOrHandles self mUids mHandles = do listUsersByIdsOrHandles :: forall r. - Members - '[ GalleyProvider, - Concurrency 'Unsafe - ] - r => + ( Members + '[ GalleyProvider, + Concurrency 'Unsafe + ] + r, + CallsFed 'Brig "get-users-by-ids" + ) => UserId -> Public.ListUsersQuery -> (Handler r) [Public.UserProfile] @@ -751,7 +775,7 @@ newtype GetActivationCodeResp instance ToJSON GetActivationCodeResp where toJSON (GetActivationCodeResp (k, c)) = object ["key" .= k, "code" .= c] -updateUser :: UserId -> ConnId -> Public.UserUpdate -> (Handler r) (Maybe Public.UpdateProfileError) +updateUser :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> ConnId -> Public.UserUpdate -> (Handler r) (Maybe Public.UpdateProfileError) updateUser uid conn uu = do eithErr <- lift $ runExceptT $ API.updateUser uid (Just conn) uu API.ForbidSCIMUpdates pure $ either Just (const Nothing) eithErr @@ -772,11 +796,11 @@ changePhone u _ (Public.puPhone -> phone) = lift . exceptTToMaybe $ do let apair = (activationKey adata, activationCode adata) lift . wrapClient $ sendActivationSms pn apair loc -removePhone :: UserId -> ConnId -> (Handler r) (Maybe Public.RemoveIdentityError) +removePhone :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> ConnId -> (Handler r) (Maybe Public.RemoveIdentityError) removePhone self conn = lift . exceptTToMaybe $ API.removePhone self conn -removeEmail :: UserId -> ConnId -> (Handler r) (Maybe Public.RemoveIdentityError) +removeEmail :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> ConnId -> (Handler r) (Maybe Public.RemoveIdentityError) removeEmail self conn = lift . exceptTToMaybe $ API.removeEmail self conn @@ -786,7 +810,7 @@ checkPasswordExists = fmap isJust . lift . wrapClient . API.lookupPassword changePassword :: UserId -> Public.PasswordChange -> (Handler r) (Maybe Public.ChangePasswordError) changePassword u cp = lift . exceptTToMaybe $ API.changePassword u cp -changeLocale :: UserId -> ConnId -> Public.LocaleUpdate -> (Handler r) () +changeLocale :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> ConnId -> Public.LocaleUpdate -> (Handler r) () changeLocale u conn l = lift $ API.changeLocale u conn l -- | (zusr is ignored by this handler, ie. checking handles is allowed as long as you have @@ -810,10 +834,13 @@ checkHandles _ (Public.CheckHandles hs num) = do -- 'Handle.getHandleInfo') returns UserProfile to reduce traffic between backends -- in a federated scenario. getHandleInfoUnqualifiedH :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "get-user-by-handle", + CallsFed 'Brig "get-users-by-ids" + ) => UserId -> Handle -> (Handler r) (Maybe Public.UserHandleInfo) @@ -822,7 +849,7 @@ getHandleInfoUnqualifiedH self handle = do Public.UserHandleInfo . Public.profileQualifiedId <$$> Handle.getHandleInfo self (Qualified handle domain) -changeHandle :: UserId -> ConnId -> Public.HandleUpdate -> (Handler r) (Maybe Public.ChangeHandleError) +changeHandle :: (CallsFed 'Brig "on-user-deleted-connections") => UserId -> ConnId -> Public.HandleUpdate -> (Handler r) (Maybe Public.ChangeHandleError) changeHandle u conn (Public.HandleUpdate h) = lift . exceptTToMaybe $ do handle <- maybe (throwError Public.ChangeHandleInvalid) pure $ parseHandle h API.changeHandle u (Just conn) handle API.ForbidSCIMUpdates @@ -879,10 +906,12 @@ customerExtensionCheckBlockedDomains email = do customerExtensionBlockedDomain domain createConnectionUnqualified :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "send-connection-action" + ) => UserId -> ConnId -> Public.ConnectionRequest -> @@ -893,10 +922,12 @@ createConnectionUnqualified self conn cr = do API.createConnection lself conn (tUntagged target) !>> connError createConnection :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "send-connection-action" + ) => UserId -> ConnId -> Qualified UserId -> @@ -905,12 +936,12 @@ createConnection self conn target = do lself <- qualifyLocal self API.createConnection lself conn target !>> connError -updateLocalConnection :: UserId -> ConnId -> UserId -> Public.ConnectionUpdate -> (Handler r) (Public.UpdateResult Public.UserConnection) +updateLocalConnection :: (CallsFed 'Brig "send-connection-action") => UserId -> ConnId -> UserId -> Public.ConnectionUpdate -> (Handler r) (Public.UpdateResult Public.UserConnection) updateLocalConnection self conn other update = do lother <- qualifyLocal other updateConnection self conn (tUntagged lother) update -updateConnection :: UserId -> ConnId -> Qualified UserId -> Public.ConnectionUpdate -> (Handler r) (Public.UpdateResult Public.UserConnection) +updateConnection :: (CallsFed 'Brig "send-connection-action") => UserId -> ConnId -> Qualified UserId -> Public.ConnectionUpdate -> (Handler r) (Public.UpdateResult Public.UserConnection) updateConnection self conn other update = do let newStatus = Public.cuStatus update lself <- qualifyLocal self @@ -976,17 +1007,19 @@ getConnection self other = do lift . wrapClient $ Data.lookupConnection lself other deleteSelfUser :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => UserId -> Public.DeleteUser -> (Handler r) (Maybe Code.Timeout) deleteSelfUser u body = API.deleteSelfUser u (Public.deleteUserPassword body) !>> deleteUserError -verifyDeleteUser :: Public.VerifyDeleteUser -> Handler r () +verifyDeleteUser :: (CallsFed 'Brig "on-user-deleted-connections") => Public.VerifyDeleteUser -> Handler r () verifyDeleteUser body = API.verifyDeleteUser body !>> deleteUserError updateUserEmail :: @@ -1023,10 +1056,12 @@ updateUserEmail zuserId emailOwnerId (Public.EmailUpdate email) = do -- activation activate :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Public.ActivationKey -> Public.ActivationCode -> (Handler r) ActivationRespWithStatus @@ -1036,10 +1071,12 @@ activate k c = do -- docs/reference/user/activation.md {#RefActivationSubmit} activateKey :: - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Public.Activate -> (Handler r) ActivationRespWithStatus activateKey (Public.Activate tgt code dryrun) diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index dc65ae341d..a58f31f713 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -172,6 +172,7 @@ import UnliftIO.Async import Wire.API.Connection import Wire.API.Error import qualified Wire.API.Error.Brig as E +import Wire.API.Federation.API import Wire.API.Federation.Error import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Team hiding (newTeam) @@ -227,10 +228,12 @@ verifyUniquenessAndCheckBlacklist uk = do createUserSpar :: forall r. - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => NewUserSpar -> ExceptT CreateUserSparError (AppT r) CreateUserResult createUserSpar new = do @@ -293,12 +296,14 @@ createUserSpar new = do -- docs/reference/user/registration.md {#RefRegistration} createUser :: forall r p. - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => NewUser -> ExceptT RegisterError (AppT r) CreateUserResult createUser new = do @@ -582,7 +587,7 @@ checkRestrictedUserCreation new = do ------------------------------------------------------------------------------- -- Update Profile -updateUser :: UserId -> Maybe ConnId -> UserUpdate -> AllowSCIMUpdates -> ExceptT UpdateProfileError (AppT r) () +updateUser :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> Maybe ConnId -> UserUpdate -> AllowSCIMUpdates -> ExceptT UpdateProfileError (AppT r) () updateUser uid mconn uu allowScim = do for_ (uupName uu) $ \newName -> do mbUser <- lift . wrapClient $ Data.lookupUser WithPendingInvitations uid @@ -600,7 +605,7 @@ updateUser uid mconn uu allowScim = do ------------------------------------------------------------------------------- -- Update Locale -changeLocale :: UserId -> ConnId -> LocaleUpdate -> (AppT r) () +changeLocale :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> ConnId -> LocaleUpdate -> (AppT r) () changeLocale uid conn (LocaleUpdate loc) = do wrapClient $ Data.updateLocale uid loc wrapHttpClient $ Intra.onUserEvent uid (Just conn) (localeUpdate uid loc) @@ -608,7 +613,7 @@ changeLocale uid conn (LocaleUpdate loc) = do ------------------------------------------------------------------------------- -- Update ManagedBy -changeManagedBy :: UserId -> ConnId -> ManagedByUpdate -> (AppT r) () +changeManagedBy :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> ConnId -> ManagedByUpdate -> (AppT r) () changeManagedBy uid conn (ManagedByUpdate mb) = do wrapClient $ Data.updateManagedBy uid mb wrapHttpClient $ Intra.onUserEvent uid (Just conn) (managedByUpdate uid mb) @@ -616,7 +621,7 @@ changeManagedBy uid conn (ManagedByUpdate mb) = do -------------------------------------------------------------------------------- -- Change Handle -changeHandle :: UserId -> Maybe ConnId -> Handle -> AllowSCIMUpdates -> ExceptT ChangeHandleError (AppT r) () +changeHandle :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> Maybe ConnId -> Handle -> AllowSCIMUpdates -> ExceptT ChangeHandleError (AppT r) () changeHandle uid mconn hdl allowScim = do when (isBlacklistedHandle hdl) $ throwE ChangeHandleInvalid @@ -774,7 +779,7 @@ changePhone u phone = do ------------------------------------------------------------------------------- -- Remove Email -removeEmail :: UserId -> ConnId -> ExceptT RemoveIdentityError (AppT r) () +removeEmail :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> ConnId -> ExceptT RemoveIdentityError (AppT r) () removeEmail uid conn = do ident <- lift $ fetchUserIdentity uid case ident of @@ -788,7 +793,7 @@ removeEmail uid conn = do ------------------------------------------------------------------------------- -- Remove Phone -removePhone :: UserId -> ConnId -> ExceptT RemoveIdentityError (AppT r) () +removePhone :: CallsFed 'Brig "on-user-deleted-connections" => UserId -> ConnId -> ExceptT RemoveIdentityError (AppT r) () removePhone uid conn = do ident <- lift $ fetchUserIdentity uid case ident of @@ -806,7 +811,7 @@ removePhone uid conn = do ------------------------------------------------------------------------------- -- Forcefully revoke a verified identity -revokeIdentity :: Either Email Phone -> AppT r () +revokeIdentity :: CallsFed 'Brig "on-user-deleted-connections" => Either Email Phone -> AppT r () revokeIdentity key = do let uk = either userEmailKey userPhoneKey key mu <- wrapClient $ Data.lookupKey uk @@ -850,7 +855,8 @@ changeAccountStatus :: MonadMask m, MonadHttp m, HasRequestId m, - MonadUnliftIO m + MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections" ) => List1 UserId -> AccountStatus -> @@ -876,7 +882,8 @@ changeSingleAccountStatus :: MonadMask m, MonadHttp m, HasRequestId m, - MonadUnliftIO m + MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> AccountStatus -> @@ -901,7 +908,7 @@ mkUserEvent usrs status = -- Activation activate :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => ActivationTarget -> ActivationCode -> -- | The user for whom to activate the key. @@ -910,7 +917,7 @@ activate :: activate tgt code usr = activateWithCurrency tgt code usr Nothing activateWithCurrency :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => ActivationTarget -> ActivationCode -> -- | The user for whom to activate the key. @@ -941,7 +948,8 @@ activateWithCurrency tgt code usr cur = do preverify :: ( MonadClient m, - MonadReader Env m + MonadReader Env m, + CallsFed 'Brig "on-user-deleted-connections" ) => ActivationTarget -> ActivationCode -> @@ -950,7 +958,7 @@ preverify tgt code = do key <- mkActivationKey tgt void $ Data.verifyCode key code -onActivated :: ActivationEvent -> (AppT r) (UserId, Maybe UserIdentity, Bool) +onActivated :: CallsFed 'Brig "on-user-deleted-connections" => ActivationEvent -> (AppT r) (UserId, Maybe UserIdentity, Bool) onActivated (AccountActivated account) = do let uid = userId (accountUser account) Log.debug $ field "user" (toByteString uid) . field "action" (Log.val "User.onActivated") @@ -1167,10 +1175,12 @@ mkPasswordResetKey ident = case ident of -- TODO: communicate deletions of SSO users to SSO service. deleteSelfUser :: forall r. - Members - '[ GalleyProvider - ] - r => + ( Members + '[ GalleyProvider + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => UserId -> Maybe PlainTextPassword -> ExceptT DeleteUserError (AppT r) (Maybe Timeout) @@ -1246,7 +1256,7 @@ deleteSelfUser uid pwd = do -- | Conclude validation and scheduling of user's deletion request that was initiated in -- 'deleteUser'. Called via @post /delete@. -verifyDeleteUser :: VerifyDeleteUser -> ExceptT DeleteUserError (AppT r) () +verifyDeleteUser :: CallsFed 'Brig "on-user-deleted-connections" => VerifyDeleteUser -> ExceptT DeleteUserError (AppT r) () verifyDeleteUser d = do let key = verifyDeleteUserKey d let code = verifyDeleteUserCode d @@ -1270,7 +1280,8 @@ ensureAccountDeleted :: HasRequestId m, MonadUnliftIO m, MonadClient m, - MonadReader Env m + MonadReader Env m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> m DeleteUserResult @@ -1315,7 +1326,8 @@ deleteAccount :: MonadHttp m, HasRequestId m, MonadUnliftIO m, - MonadClient m + MonadClient m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserAccount -> m () @@ -1422,7 +1434,7 @@ userGC u = case userExpire u of pure u lookupProfile :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-users-by-ids") => Local UserId -> Qualified UserId -> ExceptT FederationError (AppT r) (Maybe UserProfile) @@ -1438,11 +1450,13 @@ lookupProfile self other = -- Otherwise only the 'PublicProfile' is accessible for user 'self'. -- If 'self' is an unknown 'UserId', return '[]'. lookupProfiles :: - Members - '[ GalleyProvider, - Concurrency 'Unsafe - ] - r => + ( Members + '[ GalleyProvider, + Concurrency 'Unsafe + ] + r, + CallsFed 'Brig "get-users-by-ids" + ) => -- | User 'self' on whose behalf the profiles are requested. Local UserId -> -- | The users ('others') for which to obtain the profiles. @@ -1455,7 +1469,7 @@ lookupProfiles self others = (bucketQualified others) lookupProfilesFromDomain :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-users-by-ids") => Local UserId -> Qualified [UserId] -> ExceptT FederationError (AppT r) [UserProfile] @@ -1468,7 +1482,8 @@ lookupProfilesFromDomain self = lookupRemoteProfiles :: ( MonadIO m, MonadReader Env m, - MonadLogger m + MonadLogger m, + CallsFed 'Brig "get-users-by-ids" ) => Remote [UserId] -> ExceptT FederationError m [UserProfile] diff --git a/services/brig/src/Brig/Federation/Client.hs b/services/brig/src/Brig/Federation/Client.hs index 1b38057912..37eb4924ba 100644 --- a/services/brig/src/Brig/Federation/Client.hs +++ b/services/brig/src/Brig/Federation/Client.hs @@ -47,6 +47,7 @@ import Wire.API.UserMap getUserHandleInfo :: ( MonadReader Env m, MonadIO m, + CallsFed 'Brig "get-user-by-handle", Log.MonadLogger m ) => Remote Handle -> @@ -58,6 +59,7 @@ getUserHandleInfo (tUntagged -> Qualified handle domain) = do getUsersByIds :: ( MonadReader Env m, MonadIO m, + CallsFed 'Brig "get-users-by-ids", Log.MonadLogger m ) => Domain -> @@ -68,7 +70,7 @@ getUsersByIds domain uids = do runBrigFederatorClient domain $ fedClient @'Brig @"get-users-by-ids" uids claimPrekey :: - (MonadReader Env m, MonadIO m, Log.MonadLogger m) => + (MonadReader Env m, MonadIO m, Log.MonadLogger m, CallsFed 'Brig "claim-prekey") => Qualified UserId -> ClientId -> ExceptT FederationError m (Maybe ClientPrekey) @@ -79,6 +81,7 @@ claimPrekey (Qualified user domain) client = do claimPrekeyBundle :: ( MonadReader Env m, MonadIO m, + CallsFed 'Brig "claim-prekey-bundle", Log.MonadLogger m ) => Qualified UserId -> @@ -90,7 +93,8 @@ claimPrekeyBundle (Qualified user domain) = do claimMultiPrekeyBundle :: ( Log.MonadLogger m, MonadReader Env m, - MonadIO m + MonadIO m, + CallsFed 'Brig "claim-multi-prekey-bundle" ) => Domain -> UserClients -> @@ -102,7 +106,8 @@ claimMultiPrekeyBundle domain uc = do searchUsers :: ( MonadReader Env m, MonadIO m, - Log.MonadLogger m + Log.MonadLogger m, + CallsFed 'Brig "search-users" ) => Domain -> SearchRequest -> @@ -114,7 +119,8 @@ searchUsers domain searchTerm = do getUserClients :: ( MonadReader Env m, MonadIO m, - Log.MonadLogger m + Log.MonadLogger m, + CallsFed 'Brig "get-user-clients" ) => Domain -> GetUserClients -> @@ -124,7 +130,7 @@ getUserClients domain guc = do runBrigFederatorClient domain $ fedClient @'Brig @"get-user-clients" guc sendConnectionAction :: - (MonadReader Env m, MonadIO m, Log.MonadLogger m) => + (MonadReader Env m, MonadIO m, Log.MonadLogger m, CallsFed 'Brig "send-connection-action") => Local UserId -> Remote UserId -> RemoteConnectionAction -> diff --git a/services/brig/src/Brig/IO/Intra.hs b/services/brig/src/Brig/IO/Intra.hs index 3dae8bbbe1..922ef8b67d 100644 --- a/services/brig/src/Brig/IO/Intra.hs +++ b/services/brig/src/Brig/IO/Intra.hs @@ -98,6 +98,7 @@ import qualified System.Logger.Extended as ExLog import Wire.API.Connection import Wire.API.Conversation import Wire.API.Event.Conversation (Connect (Connect)) +import Wire.API.Federation.API import Wire.API.Federation.API.Brig import Wire.API.Federation.Error import Wire.API.Properties @@ -117,7 +118,8 @@ onUserEvent :: MonadHttp m, HasRequestId m, MonadUnliftIO m, - MonadClient m + MonadClient m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> Maybe ConnId -> @@ -249,7 +251,8 @@ dispatchNotifications :: MonadHttp m, HasRequestId m, MonadUnliftIO m, - MonadClient m + MonadClient m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> Maybe ConnId -> @@ -285,6 +288,7 @@ notifyUserDeletionLocals :: MonadHttp m, HasRequestId m, MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections", MonadClient m ) => UserId -> @@ -299,7 +303,8 @@ notifyUserDeletionRemotes :: forall m. ( MonadReader Env m, MonadClient m, - MonadLogger m + MonadLogger m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> m () diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index 7a05784e40..31bbf7076a 100644 --- a/services/brig/src/Brig/InternalEvent/Process.hs +++ b/services/brig/src/Brig/InternalEvent/Process.hs @@ -39,6 +39,7 @@ import Imports import System.Logger.Class (field, msg, val, (~~)) import qualified System.Logger.Class as Log import UnliftIO (timeout) +import Wire.API.Federation.API -- | Handle an internal event. -- @@ -52,7 +53,8 @@ onEvent :: MonadHttp m, HasRequestId m, MonadUnliftIO m, - MonadClient m + MonadClient m, + CallsFed 'Brig "on-user-deleted-connections" ) => InternalNotification -> m () diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 66c8a5fda0..62c7466098 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -1,4 +1,5 @@ {-# LANGUAGE NumericUnderscores #-} +{-# OPTIONS_GHC -Wno-orphans #-} -- This file is part of the Wire Server implementation. -- @@ -73,12 +74,17 @@ import qualified Servant import System.Logger (msg, val, (.=), (~~)) import System.Logger.Class (MonadLogger, err) import Util.Options +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Brig import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import qualified Wire.Sem.Paging as P +-- | Orphan instance to satisfy 'CallsFeds' constraints, which we otherwise use +-- to track federation calls across the codebase. +instance {-# OVERLAPPING #-} CallsFed comp name + -- FUTUREWORK: If any of these async threads die, we will have no clue about it -- and brig could start misbehaving. We should ensure that brig dies whenever a -- thread terminates for any reason. diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index d5814a1116..c8e2776bd3 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -67,6 +67,7 @@ import qualified System.Logger.Class as Log import Util.Logging (logFunction, logTeam) import Wire.API.Error import qualified Wire.API.Error.Brig as E +import Wire.API.Federation.API import Wire.API.Routes.Named import Wire.API.Routes.Public.Brig import Wire.API.Team @@ -97,12 +98,14 @@ servantAPI = :<|> Named @"get-team-size" teamSizePublic routesInternal :: - Members - '[ BlacklistStore, - GalleyProvider, - UserPendingActivationStore p - ] - r => + ( Members + '[ BlacklistStore, + GalleyProvider, + UserPendingActivationStore p + ] + r, + CallsFed 'Brig "on-user-deleted-connections" + ) => Routes a (Handler r) () routesInternal = do get "/i/teams/invitations/by-email" (continue getInvitationByEmailH) $ @@ -377,25 +380,25 @@ getInvitationByEmail email = do inv <- lift $ wrapClient $ DB.lookupInvitationByEmail HideInvitationUrl email maybe (throwStd (notFound "Invitation not found")) pure inv -suspendTeamH :: Members '[GalleyProvider] r => JSON ::: TeamId -> (Handler r) Response +suspendTeamH :: (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => JSON ::: TeamId -> (Handler r) Response suspendTeamH (_ ::: tid) = do empty <$ suspendTeam tid -suspendTeam :: Members '[GalleyProvider] r => TeamId -> (Handler r) () +suspendTeam :: (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => TeamId -> (Handler r) () suspendTeam tid = do changeTeamAccountStatuses tid Suspended lift $ wrapClient $ DB.deleteInvitations tid lift $ liftSem $ GalleyProvider.changeTeamStatus tid Team.Suspended Nothing unsuspendTeamH :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => JSON ::: TeamId -> (Handler r) Response unsuspendTeamH (_ ::: tid) = do empty <$ unsuspendTeam tid unsuspendTeam :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => TeamId -> (Handler r) () unsuspendTeam tid = do @@ -406,7 +409,7 @@ unsuspendTeam tid = do -- Internal changeTeamAccountStatuses :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => TeamId -> AccountStatus -> (Handler r) () diff --git a/services/brig/src/Brig/User/API/Handle.hs b/services/brig/src/Brig/User/API/Handle.hs index fb3d49c4f1..7d3c37e878 100644 --- a/services/brig/src/Brig/User/API/Handle.hs +++ b/services/brig/src/Brig/User/API/Handle.hs @@ -39,13 +39,14 @@ import Imports import Network.Wai.Utilities ((!>>)) import Polysemy import qualified System.Logger.Class as Log +import Wire.API.Federation.API import Wire.API.User import qualified Wire.API.User as Public import Wire.API.User.Search import qualified Wire.API.User.Search as Public getHandleInfo :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-user-by-handle", CallsFed 'Brig "get-users-by-ids") => UserId -> Qualified Handle -> (Handler r) (Maybe Public.UserProfile) @@ -57,7 +58,7 @@ getHandleInfo self handle = do getRemoteHandleInfo handle -getRemoteHandleInfo :: Remote Handle -> (Handler r) (Maybe Public.UserProfile) +getRemoteHandleInfo :: CallsFed 'Brig "get-user-by-handle" => Remote Handle -> (Handler r) (Maybe Public.UserProfile) getRemoteHandleInfo handle = do lift . Log.info $ Log.msg (Log.val "getHandleInfo - remote lookup") @@ -65,7 +66,7 @@ getRemoteHandleInfo handle = do Federation.getUserHandleInfo handle !>> fedError getLocalHandleInfo :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-users-by-ids") => Local UserId -> Handle -> (Handler r) (Maybe Public.UserProfile) diff --git a/services/brig/src/Brig/User/API/Search.hs b/services/brig/src/Brig/User/API/Search.hs index 0706471c70..2713256f82 100644 --- a/services/brig/src/Brig/User/API/Search.hs +++ b/services/brig/src/Brig/User/API/Search.hs @@ -50,6 +50,7 @@ import Polysemy import System.Logger (field, msg) import System.Logger.Class (val, (~~)) import qualified System.Logger.Class as Log +import Wire.API.Federation.API import qualified Wire.API.Federation.API.Brig as FedBrig import qualified Wire.API.Federation.API.Brig as S import qualified Wire.API.Team.Permission as Public @@ -85,7 +86,7 @@ routesInternal = do -- FUTUREWORK: Consider augmenting 'SearchResult' with full user profiles -- for all results. This is tracked in https://wearezeta.atlassian.net/browse/SQCORE-599 search :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-users-by-ids", CallsFed 'Brig "search-users") => UserId -> Text -> Maybe Domain -> @@ -98,7 +99,7 @@ search searcherId searchTerm maybeDomain maybeMaxResults = do then searchLocally searcherId searchTerm maybeMaxResults else searchRemotely queryDomain searchTerm -searchRemotely :: Domain -> Text -> (Handler r) (Public.SearchResult Public.Contact) +searchRemotely :: CallsFed 'Brig "search-users" => Domain -> Text -> (Handler r) (Public.SearchResult Public.Contact) searchRemotely domain searchTerm = do lift . Log.info $ msg (val "searchRemotely") @@ -120,7 +121,7 @@ searchRemotely domain searchTerm = do searchLocally :: forall r. - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "get-users-by-ids") => UserId -> Text -> Maybe (Range 1 500 Int32) -> diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index f1c104e467..39fc1b7462 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -78,6 +78,7 @@ import Network.Wai.Utilities.Error ((!>>)) import Polysemy import System.Logger (field, msg, val, (~~)) import qualified System.Logger.Class as Log +import Wire.API.Federation.API import Wire.API.Team.Feature import qualified Wire.API.Team.Feature as Public import Wire.API.User @@ -134,7 +135,7 @@ lookupLoginCode phone = login :: forall r. - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => Login -> CookieType -> ExceptT LoginError (AppT r) (Access ZAuth.User) @@ -251,7 +252,8 @@ renewAccess :: MonadMask m, MonadHttp m, HasRequestId m, - MonadUnliftIO m + MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections" ) => List1 (ZAuth.Token u) -> Maybe (ZAuth.Token a) -> @@ -289,7 +291,8 @@ catchSuspendInactiveUser :: MonadHttp m, HasRequestId m, MonadUnliftIO m, - Log.MonadLogger m + Log.MonadLogger m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> e -> @@ -321,7 +324,8 @@ newAccess :: MonadMask m, MonadHttp m, HasRequestId m, - MonadUnliftIO m + MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections" ) => UserId -> Maybe ClientId -> @@ -442,7 +446,8 @@ ssoLogin :: MonadMask m, MonadHttp m, HasRequestId m, - MonadUnliftIO m + MonadUnliftIO m, + CallsFed 'Brig "on-user-deleted-connections" ) => SsoLogin -> CookieType -> @@ -463,7 +468,7 @@ ssoLogin (SsoLogin uid label) typ = do -- | Log in as a LegalHold service, getting LegalHoldUser/Access Tokens. legalHoldLogin :: - Members '[GalleyProvider] r => + (Members '[GalleyProvider] r, CallsFed 'Brig "on-user-deleted-connections") => LegalHoldLogin -> CookieType -> ExceptT LegalHoldLoginError (AppT r) (Access ZAuth.LegalHoldUser) diff --git a/services/cargohold/src/CargoHold/API/Public.hs b/services/cargohold/src/CargoHold/API/Public.hs index bbbbb5091d..18f85a9f71 100644 --- a/services/cargohold/src/CargoHold/API/Public.hs +++ b/services/cargohold/src/CargoHold/API/Public.hs @@ -35,11 +35,12 @@ import Servant.API import Servant.Server hiding (Handler) import URI.ByteString import Wire.API.Asset +import Wire.API.Federation.API import Wire.API.Routes.AssetBody import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold -servantSitemap :: ServerT ServantAPI Handler +servantSitemap :: (CallsFed 'Cargohold "get-asset", CallsFed 'Cargohold "stream-asset") => ServerT ServantAPI Handler servantSitemap = renewTokenV3 :<|> deleteTokenV3 @@ -147,6 +148,7 @@ downloadAssetV3 usr key tok1 tok2 = do AssetLocation <$$> V3.download (mkPrincipal usr) key (tok1 <|> tok2) downloadAssetV4 :: + (CallsFed 'Cargohold "get-asset", CallsFed 'Cargohold "stream-asset") => Local UserId -> Qualified AssetKey -> Maybe AssetToken -> diff --git a/services/cargohold/src/CargoHold/Federation.hs b/services/cargohold/src/CargoHold/Federation.hs index 6949929ea8..94a8bebc7e 100644 --- a/services/cargohold/src/CargoHold/Federation.hs +++ b/services/cargohold/src/CargoHold/Federation.hs @@ -48,6 +48,7 @@ import Wire.API.Federation.Error -- is streamed back through our outward federator, as well as the remote one. downloadRemoteAsset :: + (CallsFed 'Cargohold "get-asset", CallsFed 'Cargohold "stream-asset") => Local UserId -> Remote AssetKey -> Maybe AssetToken -> diff --git a/services/cargohold/src/CargoHold/Run.hs b/services/cargohold/src/CargoHold/Run.hs index 09677b898e..d0675dcf5a 100644 --- a/services/cargohold/src/CargoHold/Run.hs +++ b/services/cargohold/src/CargoHold/Run.hs @@ -15,6 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . {-# LANGUAGE NumericUnderscores #-} +{-# OPTIONS_GHC -Wno-orphans #-} module CargoHold.Run ( run, @@ -50,6 +51,7 @@ import Servant.API import Servant.Server hiding (Handler, runHandler) import qualified UnliftIO.Async as Async import Util.Options +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold @@ -57,6 +59,8 @@ import Wire.API.Routes.Version.Wai type CombinedAPI = FederationAPI :<|> ServantAPI :<|> InternalAPI +instance CallsFed comp name + run :: Opts -> IO () run o = lowerCodensity $ do (app, e) <- mkApp o diff --git a/services/federator/test/unit/Test/Federator/Client.hs b/services/federator/test/unit/Test/Federator/Client.hs index 61825a7e17..0a99e08f15 100644 --- a/services/federator/test/unit/Test/Federator/Client.hs +++ b/services/federator/test/unit/Test/Federator/Client.hs @@ -14,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-orphans #-} module Test.Federator.Client (tests) where @@ -50,6 +51,8 @@ import Wire.API.Federation.Component import Wire.API.Federation.Error import Wire.API.User (UserProfile) +instance CallsFed comp name + targetDomain :: Domain targetDomain = Domain "target.example.com" diff --git a/services/galley/src/Galley/API/Action.hs b/services/galley/src/Galley/API/Action.hs index eb5f17d2f0..03a344533b 100644 --- a/services/galley/src/Galley/API/Action.hs +++ b/services/galley/src/Galley/API/Action.hs @@ -86,7 +86,7 @@ import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Event.Conversation -import Wire.API.Federation.API (Component (Galley), fedClient) +import Wire.API.Federation.API (CallsFed, Component (Galley), fedClient) import Wire.API.Federation.API.Galley import Wire.API.Federation.Error import Wire.API.Team.LegalHold @@ -276,7 +276,11 @@ ensureAllowed tag loc action conv origUser = do -- and also returns the (possible modified) action that was performed performAction :: forall tag r. - (HasConversationActionEffects tag r) => + ( HasConversationActionEffects tag r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" + ) => Sing tag -> Qualified UserId -> Local Conversation -> @@ -344,7 +348,11 @@ performAction tag origUser lconv action = do pure (bm, act) performConversationJoin :: - (HasConversationActionEffects 'ConversationJoinTag r) => + ( HasConversationActionEffects 'ConversationJoinTag r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" + ) => Qualified UserId -> Local Conversation -> ConversationJoin -> @@ -470,7 +478,11 @@ performConversationJoin qusr lconv (ConversationJoin invited role) = do checkLHPolicyConflictsRemote _remotes = pure () performConversationAccessData :: - (HasConversationActionEffects 'ConversationAccessDataTag r) => + ( HasConversationActionEffects 'ConversationAccessDataTag r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" + ) => Qualified UserId -> Local Conversation -> ConversationAccessData -> @@ -568,7 +580,10 @@ updateLocalConversation :: ] r, HasConversationActionEffects tag r, - SingI tag + SingI tag, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-conversation-updated" ) => Local ConvId -> Qualified UserId -> @@ -605,7 +620,10 @@ updateLocalConversationUnchecked :: Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, - HasConversationActionEffects tag r + HasConversationActionEffects tag r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-conversation-updated" ) => Local Conversation -> Qualified UserId -> @@ -681,7 +699,10 @@ addMembersToLocalConversation lcnv users role = do notifyConversationAction :: forall tag r. - Members '[FederatorAccess, ExternalAccess, GundeckAccess, Input UTCTime] r => + ( Members '[FederatorAccess, ExternalAccess, GundeckAccess, Input UTCTime] r, + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-conversation-updated" + ) => Sing tag -> Qualified UserId -> Bool -> @@ -797,7 +818,10 @@ kickMember :: Member (Input UTCTime) r, Member (Input Env) r, Member MemberStore r, - Member TinyLog r + Member TinyLog r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" ) => Qualified UserId -> Local Conversation -> diff --git a/services/galley/src/Galley/API/Clients.hs b/services/galley/src/Galley/API/Clients.hs index 95aacd8275..d671b33621 100644 --- a/services/galley/src/Galley/API/Clients.hs +++ b/services/galley/src/Galley/API/Clients.hs @@ -104,7 +104,9 @@ rmClientH :: ProposalStore, P.TinyLog ] - r + r, + CallsFed 'Galley "on-client-removed", + CallsFed 'Galley "on-mls-message-sent" ) => UserId ::: ClientId -> Sem r Response diff --git a/services/galley/src/Galley/API/Create.hs b/services/galley/src/Galley/API/Create.hs index 99b00d9c70..23ab271c15 100644 --- a/services/galley/src/Galley/API/Create.hs +++ b/services/galley/src/Galley/API/Create.hs @@ -71,6 +71,7 @@ import Wire.API.Conversation.Protocol import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Event.Conversation +import Wire.API.Federation.API import Wire.API.Federation.Error import Wire.API.Routes.Public.Galley.Conversation import Wire.API.Routes.Public.Util @@ -84,29 +85,31 @@ import Wire.API.Team.Permission hiding (self) -- | The public-facing endpoint for creating group conversations. createGroupConversation :: - Members - '[ BrigAccess, - ConversationStore, - MemberStore, - ErrorS 'ConvAccessDenied, - Error InternalError, - Error InvalidInput, - ErrorS 'NotATeamMember, - ErrorS OperationDenied, - ErrorS 'NotConnected, - ErrorS 'MLSNotEnabled, - ErrorS 'MLSNonEmptyMemberList, - ErrorS 'MissingLegalholdConsent, - FederatorAccess, - GundeckAccess, - Input Env, - Input Opts, - Input UTCTime, - LegalHoldStore, - TeamStore, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + MemberStore, + ErrorS 'ConvAccessDenied, + Error InternalError, + Error InvalidInput, + ErrorS 'NotATeamMember, + ErrorS OperationDenied, + ErrorS 'NotConnected, + ErrorS 'MLSNotEnabled, + ErrorS 'MLSNonEmptyMemberList, + ErrorS 'MissingLegalholdConsent, + FederatorAccess, + GundeckAccess, + Input Env, + Input Opts, + Input UTCTime, + LegalHoldStore, + TeamStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local UserId -> ConnId -> NewConv -> @@ -218,29 +221,31 @@ createProteusSelfConversation lusr = do createOne2OneConversation :: forall r. - Members - '[ BrigAccess, - ConversationStore, - ErrorS 'ConvAccessDenied, - Error FederationError, - Error InternalError, - Error InvalidInput, - ErrorS 'ConvAccessDenied, - ErrorS 'NotATeamMember, - ErrorS 'NonBindingTeam, - ErrorS 'NoBindingTeamMembers, - ErrorS OperationDenied, - ErrorS 'TeamNotFound, - ErrorS 'InvalidOperation, - ErrorS 'NotConnected, - ErrorS 'MissingLegalholdConsent, - FederatorAccess, - GundeckAccess, - Input UTCTime, - TeamStore, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + ErrorS 'ConvAccessDenied, + Error FederationError, + Error InternalError, + Error InvalidInput, + ErrorS 'ConvAccessDenied, + ErrorS 'NotATeamMember, + ErrorS 'NonBindingTeam, + ErrorS 'NoBindingTeamMembers, + ErrorS OperationDenied, + ErrorS 'TeamNotFound, + ErrorS 'InvalidOperation, + ErrorS 'NotConnected, + ErrorS 'MissingLegalholdConsent, + FederatorAccess, + GundeckAccess, + Input UTCTime, + TeamStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local UserId -> ConnId -> NewConv -> @@ -285,16 +290,18 @@ createOne2OneConversation lusr zcon j = do Nothing -> throwS @'TeamNotFound createLegacyOne2OneConversationUnchecked :: - Members - '[ ConversationStore, - Error InternalError, - Error InvalidInput, - FederatorAccess, - GundeckAccess, - Input UTCTime, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + Error InvalidInput, + FederatorAccess, + GundeckAccess, + Input UTCTime, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local UserId -> ConnId -> Maybe (Range 1 256 Text) -> @@ -324,17 +331,19 @@ createLegacyOne2OneConversationUnchecked self zcon name mtid other = do conversationCreated self c createOne2OneConversationUnchecked :: - Members - '[ ConversationStore, - Error FederationError, - Error InternalError, - ErrorS 'MissingLegalholdConsent, - FederatorAccess, - GundeckAccess, - Input UTCTime, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error FederationError, + Error InternalError, + ErrorS 'MissingLegalholdConsent, + FederatorAccess, + GundeckAccess, + Input UTCTime, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local UserId -> ConnId -> Maybe (Range 1 256 Text) -> @@ -350,16 +359,18 @@ createOne2OneConversationUnchecked self zcon name mtid other = do create (one2OneConvId (tUntagged self) other) self zcon name mtid other createOne2OneConversationLocally :: - Members - '[ ConversationStore, - Error InternalError, - ErrorS 'MissingLegalholdConsent, - FederatorAccess, - GundeckAccess, - Input UTCTime, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + ErrorS 'MissingLegalholdConsent, + FederatorAccess, + GundeckAccess, + Input UTCTime, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local ConvId -> Local UserId -> ConnId -> @@ -401,21 +412,23 @@ createOne2OneConversationRemotely _ _ _ _ _ _ = throw FederationNotImplemented createConnectConversation :: - Members - '[ ConversationStore, - ErrorS 'ConvNotFound, - Error FederationError, - Error InternalError, - Error InvalidInput, - ErrorS 'InvalidOperation, - ErrorS 'NotConnected, - FederatorAccess, - GundeckAccess, - Input UTCTime, - MemberStore, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + ErrorS 'ConvNotFound, + Error FederationError, + Error InternalError, + Error InvalidInput, + ErrorS 'InvalidOperation, + ErrorS 'NotConnected, + FederatorAccess, + GundeckAccess, + Input UTCTime, + MemberStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-created" + ) => Local UserId -> Maybe ConnId -> Connect -> @@ -538,7 +551,9 @@ conversationCreated :: conversationCreated lusr cnv = Created <$> conversationView lusr cnv notifyCreatedConversation :: - Members '[Error InternalError, FederatorAccess, GundeckAccess, Input UTCTime, P.TinyLog] r => + ( Members '[Error InternalError, FederatorAccess, GundeckAccess, Input UTCTime, P.TinyLog] r, + CallsFed 'Galley "on-conversation-created" + ) => Maybe UTCTime -> Local UserId -> Maybe ConnId -> diff --git a/services/galley/src/Galley/API/Federation.hs b/services/galley/src/Galley/API/Federation.hs index 8664eb988e..d425e11331 100644 --- a/services/galley/src/Galley/API/Federation.hs +++ b/services/galley/src/Galley/API/Federation.hs @@ -100,7 +100,18 @@ import Wire.API.ServantProto type FederationAPI = "federation" :> FedApi 'Galley -- | Convert a polysemy handler to an 'API' value. -federationSitemap :: ServerT FederationAPI (Sem GalleyEffects) +federationSitemap :: + ( CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Brig "get-mls-clients", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "send-mls-message", + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "send-mls-commit-bundle", + CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" + ) => + ServerT FederationAPI (Sem GalleyEffects) federationSitemap = Named @"on-conversation-created" onConversationCreated :<|> Named @"on-new-remote-conversation" onNewRemoteConversation @@ -133,7 +144,8 @@ onClientRemoved :: ProposalStore, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent" ) => Domain -> ClientRemovedRequest -> @@ -330,21 +342,25 @@ addLocalUsersToRemoteConv remoteConvId qAdder localUsers = do -- as of now this will not generate the necessary events on the leaver's domain leaveConversation :: - Members - '[ ConversationStore, - Error InternalError, - Error InvalidInput, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input (Local ()), - Input UTCTime, - MemberStore, - ProposalStore, - TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + Error InvalidInput, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input (Local ()), + Input UTCTime, + MemberStore, + ProposalStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Domain -> F.LeaveConversationRequest -> Sem r F.LeaveConversationResponse @@ -433,22 +449,25 @@ onMessageSent domain rmUnqualified = do (Map.filterWithKey (\(uid, _) _ -> Set.member uid members) msgs) sendMessage :: - Members - '[ BrigAccess, - ClientStore, - ConversationStore, - Error InvalidInput, - FederatorAccess, - GundeckAccess, - Input (Local ()), - Input Opts, - Input UTCTime, - ExternalAccess, - MemberStore, - TeamStore, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + ClientStore, + ConversationStore, + Error InvalidInput, + FederatorAccess, + GundeckAccess, + Input (Local ()), + Input Opts, + Input UTCTime, + ExternalAccess, + MemberStore, + TeamStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" + ) => Domain -> F.ProteusMessageSendRequest -> Sem r F.MessageSendResponse @@ -461,21 +480,25 @@ sendMessage originDomain msr = do throwErr = throw . InvalidPayload . LT.pack onUserDeleted :: - Members - '[ ConversationStore, - FederatorAccess, - FireAndForget, - ExternalAccess, - GundeckAccess, - Error InternalError, - Input (Local ()), - Input UTCTime, - Input Env, - MemberStore, - ProposalStore, - TinyLog - ] - r => + ( Members + '[ ConversationStore, + FederatorAccess, + FireAndForget, + ExternalAccess, + GundeckAccess, + Error InternalError, + Input (Local ()), + Input UTCTime, + Input Env, + MemberStore, + ProposalStore, + TinyLog + ] + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" + ) => Domain -> F.UserDeletedConversationsNotification -> Sem r EmptyResponse @@ -538,7 +561,10 @@ updateConversation :: ConversationStore, Input (Local ()) ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" ) => Domain -> F.ConversationUpdateRequest -> @@ -620,7 +646,13 @@ sendMLSCommitBundle :: P.TinyLog, ProposalStore ] - r + r, + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "send-mls-commit-bundle", + CallsFed 'Brig "get-mls-clients" ) => Domain -> F.MLSMessageSendRequest -> @@ -664,7 +696,12 @@ sendMLSMessage :: P.TinyLog, ProposalStore ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "send-mls-message", + CallsFed 'Brig "get-mls-clients" ) => Domain -> F.MLSMessageSendRequest -> diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index 6b2e9c7177..f3ac4a2b00 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -14,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -fno-warn-orphans #-} module Galley.API.Internal ( internalSitemap, @@ -112,6 +113,8 @@ import Wire.API.Team.SearchVisibility import Wire.Sem.Paging import Wire.Sem.Paging.Cassandra +instance CallsFed comp name + type LegalHoldFeatureStatusChangeErrors = '( 'ActionDenied 'RemoveConversationMember, '( AuthenticationError, diff --git a/services/galley/src/Galley/API/LegalHold.hs b/services/galley/src/Galley/API/LegalHold.hs index 6f595da9bf..cfb7e9cbae 100644 --- a/services/galley/src/Galley/API/LegalHold.hs +++ b/services/galley/src/Galley/API/LegalHold.hs @@ -72,6 +72,7 @@ import Wire.API.Conversation (ConvType (..)) import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.Federation.API import Wire.API.Provider.Service import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Routes.Public.Galley.LegalHold @@ -184,40 +185,44 @@ getSettings lzusr tid = do removeSettingsInternalPaging :: forall db r. - Members - '[ BotAccess, - BrigAccess, - CodeStore, - ConversationStore, - Error AuthenticationError, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'InvalidOperation, - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'LegalHoldDisableUnimplemented, - ErrorS 'LegalHoldNotEnabled, - ErrorS 'LegalHoldServiceNotRegistered, - ErrorS 'NotATeamMember, - ErrorS OperationDenied, - ErrorS 'UserLegalHoldIllegalOperation, - ExternalAccess, - FederatorAccess, - FireAndForget, - GundeckAccess, - Input Env, - Input (Local ()), - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamFeatureStore db, - TeamMemberStore InternalPaging, - TeamStore, - WaiRoutes - ] - r => + ( Members + '[ BotAccess, + BrigAccess, + CodeStore, + ConversationStore, + Error AuthenticationError, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'InvalidOperation, + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'LegalHoldDisableUnimplemented, + ErrorS 'LegalHoldNotEnabled, + ErrorS 'LegalHoldServiceNotRegistered, + ErrorS 'NotATeamMember, + ErrorS OperationDenied, + ErrorS 'UserLegalHoldIllegalOperation, + ExternalAccess, + FederatorAccess, + FireAndForget, + GundeckAccess, + Input Env, + Input (Local ()), + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamFeatureStore db, + TeamMemberStore InternalPaging, + TeamStore, + WaiRoutes + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => TeamFeatures.FeaturePersistentConstraint db Public.LegalholdConfig => Local UserId -> TeamId -> @@ -261,7 +266,10 @@ removeSettings :: TeamMemberStore p, TeamStore ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" ) => TeamFeatures.FeaturePersistentConstraint db Public.LegalholdConfig => UserId -> @@ -320,7 +328,10 @@ removeSettings' :: ProposalStore, P.TinyLog ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" ) => TeamId -> Sem r () @@ -388,28 +399,32 @@ getUserStatus _lzusr tid uid = do -- @withdrawExplicitConsentH@ (lots of corner cases we'd have to implement for that to pan -- out). grantConsent :: - Members - '[ BrigAccess, - ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'InvalidOperation, - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'TeamMemberNotFound, - ErrorS 'UserLegalHoldIllegalOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamStore - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'InvalidOperation, + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'TeamMemberNotFound, + ErrorS 'UserLegalHoldIllegalOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> TeamId -> Sem r GrantConsentResult @@ -427,36 +442,40 @@ grantConsent lusr tid = do -- | Request to provision a device on the legal hold service for a user requestDevice :: forall db r. - Members - '[ BrigAccess, - ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'LegalHoldNotEnabled, - ErrorS 'LegalHoldServiceBadResponse, - ErrorS 'LegalHoldServiceNotRegistered, - ErrorS 'NotATeamMember, - ErrorS 'NoUserLegalHoldConsent, - ErrorS OperationDenied, - ErrorS 'TeamMemberNotFound, - ErrorS 'UserLegalHoldAlreadyEnabled, - ErrorS 'UserLegalHoldIllegalOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input (Local ()), - Input Env, - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamFeatureStore db, - TeamStore - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'LegalHoldNotEnabled, + ErrorS 'LegalHoldServiceBadResponse, + ErrorS 'LegalHoldServiceNotRegistered, + ErrorS 'NotATeamMember, + ErrorS 'NoUserLegalHoldConsent, + ErrorS OperationDenied, + ErrorS 'TeamMemberNotFound, + ErrorS 'UserLegalHoldAlreadyEnabled, + ErrorS 'UserLegalHoldIllegalOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input (Local ()), + Input Env, + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamFeatureStore db, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => TeamFeatures.FeaturePersistentConstraint db Public.LegalholdConfig => Local UserId -> TeamId -> @@ -507,36 +526,40 @@ requestDevice lzusr tid uid = do -- since they are replaced if needed when registering new LH devices. approveDevice :: forall db r. - Members - '[ BrigAccess, - ConversationStore, - Error AuthenticationError, - Error InternalError, - ErrorS 'AccessDenied, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'LegalHoldNotEnabled, - ErrorS 'LegalHoldServiceNotRegistered, - ErrorS 'NoLegalHoldDeviceAllocated, - ErrorS 'NotATeamMember, - ErrorS 'UserLegalHoldAlreadyEnabled, - ErrorS 'UserLegalHoldIllegalOperation, - ErrorS 'UserLegalHoldNotPending, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input (Local ()), - Input Env, - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamFeatureStore db, - TeamStore - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error AuthenticationError, + Error InternalError, + ErrorS 'AccessDenied, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'LegalHoldNotEnabled, + ErrorS 'LegalHoldServiceNotRegistered, + ErrorS 'NoLegalHoldDeviceAllocated, + ErrorS 'NotATeamMember, + ErrorS 'UserLegalHoldAlreadyEnabled, + ErrorS 'UserLegalHoldIllegalOperation, + ErrorS 'UserLegalHoldNotPending, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input (Local ()), + Input Env, + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamFeatureStore db, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => TeamFeatures.FeaturePersistentConstraint db Public.LegalholdConfig => Local UserId -> ConnId -> @@ -588,31 +611,35 @@ approveDevice lzusr connId tid uid (Public.ApproveLegalHoldForUserRequest mPassw disableForUser :: forall r. - Members - '[ BrigAccess, - ConversationStore, - Error AuthenticationError, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'LegalHoldServiceNotRegistered, - ErrorS 'NotATeamMember, - ErrorS OperationDenied, - ErrorS 'UserLegalHoldIllegalOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input (Local ()), - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamStore - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error AuthenticationError, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'LegalHoldServiceNotRegistered, + ErrorS 'NotATeamMember, + ErrorS OperationDenied, + ErrorS 'UserLegalHoldIllegalOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input (Local ()), + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> TeamId -> UserId -> @@ -646,26 +673,30 @@ disableForUser lzusr tid uid (Public.DisableLegalHoldForUserRequest mPassword) = -- or disabled, make sure the affected connections are screened for policy conflict (anybody -- with no-consent), and put those connections in the appropriate blocked state. changeLegalholdStatus :: - Members - '[ BrigAccess, - ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'LegalHoldCouldNotBlockConnections, - ErrorS 'UserLegalHoldIllegalOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - LegalHoldStore, - ListItems LegacyPaging ConvId, - MemberStore, - TeamStore, - ProposalStore, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'LegalHoldCouldNotBlockConnections, + ErrorS 'UserLegalHoldIllegalOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + LegalHoldStore, + ListItems LegacyPaging ConvId, + MemberStore, + TeamStore, + ProposalStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => TeamId -> Local UserId -> UserLegalHoldStatus -> @@ -766,22 +797,26 @@ unsetTeamLegalholdWhitelistedH tid = do -- contains the hypothetical new LH status of `uid`'s so it can be consulted instead of the -- one from the database. handleGroupConvPolicyConflicts :: - Members - '[ ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - ListItems LegacyPaging ConvId, - MemberStore, - ProposalStore, - P.TinyLog, - TeamStore - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + ListItems LegacyPaging ConvId, + MemberStore, + ProposalStore, + P.TinyLog, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> UserLegalHoldStatus -> Sem r () diff --git a/services/galley/src/Galley/API/MLS/GroupInfo.hs b/services/galley/src/Galley/API/MLS/GroupInfo.hs index ea2b16c78d..46a6f530f7 100644 --- a/services/galley/src/Galley/API/MLS/GroupInfo.hs +++ b/services/galley/src/Galley/API/MLS/GroupInfo.hs @@ -45,14 +45,16 @@ type MLSGroupInfoStaticErrors = ] getGroupInfo :: - Members - '[ ConversationStore, - Error FederationError, - FederatorAccess, - Input Env, - MemberStore - ] - r => + ( Members + '[ ConversationStore, + Error FederationError, + FederatorAccess, + Input Env, + MemberStore + ] + r, + CallsFed 'Galley "query-group-info" + ) => Members MLSGroupInfoStaticErrors r => Local UserId -> Qualified ConvId -> @@ -81,7 +83,7 @@ getGroupInfoFromLocalConv qusr lcnvId = do >>= noteS @'MLSMissingGroupInfo getGroupInfoFromRemoteConv :: - Members '[Error FederationError, FederatorAccess] r => + (Members '[Error FederationError, FederatorAccess] r, CallsFed 'Galley "query-group-info") => Members MLSGroupInfoStaticErrors r => Local UserId -> Remote ConvId -> diff --git a/services/galley/src/Galley/API/MLS/Message.hs b/services/galley/src/Galley/API/MLS/Message.hs index 16fb3e71a4..b939e3ab08 100644 --- a/services/galley/src/Galley/API/MLS/Message.hs +++ b/services/galley/src/Galley/API/MLS/Message.hs @@ -140,7 +140,12 @@ postMLSMessageFromLocalUserV1 :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "send-mls-message", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Local UserId -> Maybe ClientId -> @@ -178,7 +183,12 @@ postMLSMessageFromLocalUser :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "send-mls-message", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Local UserId -> Maybe ClientId -> @@ -209,7 +219,13 @@ postMLSCommitBundle :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "send-mls-commit-bundle", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Local x -> Qualified UserId -> @@ -241,7 +257,13 @@ postMLSCommitBundleFromLocalUser :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "send-mls-commit-bundle", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Local UserId -> Maybe ClientId -> @@ -272,7 +294,12 @@ postMLSCommitBundleToLocalConv :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ClientId -> @@ -337,7 +364,8 @@ postMLSCommitBundleToRemoteConv :: MemberStore, TinyLog ] - r + r, + CallsFed 'Galley "send-mls-commit-bundle" ) => Local x -> Qualified UserId -> @@ -392,7 +420,12 @@ postMLSMessage :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "send-mls-message", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Local x -> Qualified UserId -> @@ -478,7 +511,11 @@ postMLSMessageToLocalConv :: Resource, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ClientId -> @@ -517,7 +554,8 @@ postMLSMessageToLocalConv qusr senderClient con smsg lcnv = case rmValue smsg of postMLSMessageToRemoteConv :: ( Members MLSMessageStaticErrors r, Members '[Error FederationError, TinyLog] r, - HasProposalEffects r + HasProposalEffects r, + CallsFed 'Galley "send-mls-message" ) => Local x -> Qualified UserId -> @@ -642,7 +680,11 @@ processCommit :: Member (Input (Local ())) r, Member ProposalStore r, Member BrigAccess r, - Member Resource r + Member Resource r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ClientId -> @@ -660,26 +702,28 @@ processCommit qusr senderClient con lconv mlsMeta cm epoch sender commit = do processExternalCommit :: forall r. - Members - '[ BrigAccess, - ConversationStore, - Error MLSProtocolError, - ErrorS 'ConvNotFound, - ErrorS 'MLSClientSenderUserMismatch, - ErrorS 'MLSKeyPackageRefNotFound, - ErrorS 'MLSStaleMessage, - ErrorS 'MLSMissingSenderClient, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore, - ProposalStore, - Resource, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error MLSProtocolError, + ErrorS 'ConvNotFound, + ErrorS 'MLSClientSenderUserMismatch, + ErrorS 'MLSKeyPackageRefNotFound, + ErrorS 'MLSStaleMessage, + ErrorS 'MLSMissingSenderClient, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore, + ProposalStore, + Resource, + TinyLog + ] + r, + CallsFed 'Galley "on-mls-message-sent" + ) => Qualified UserId -> Maybe ClientId -> Local Data.Conversation -> @@ -784,7 +828,11 @@ processCommitWithAction :: Member (Input (Local ())) r, Member ProposalStore r, Member BrigAccess r, - Member Resource r + Member Resource r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ClientId -> @@ -819,7 +867,11 @@ processInternalCommit :: Member (Input (Local ())) r, Member ProposalStore r, Member BrigAccess r, - Member Resource r + Member Resource r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ClientId -> @@ -1153,7 +1205,11 @@ executeProposalAction :: Member MemberStore r, Member ProposalStore r, Member TeamStore r, - Member TinyLog r + Member TinyLog r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Brig "get-mls-clients" ) => Qualified UserId -> Maybe ConnId -> @@ -1292,7 +1348,9 @@ handleNoChanges :: Monoid a => Sem (Error NoChanges ': r) a -> Sem r a handleNoChanges = fmap fold . runError getClientInfo :: - Members '[BrigAccess, FederatorAccess] r => + ( Members '[BrigAccess, FederatorAccess] r, + CallsFed 'Brig "get-mls-clients" + ) => Local x -> Qualified UserId -> SignatureSchemeTag -> @@ -1300,7 +1358,9 @@ getClientInfo :: getClientInfo loc = foldQualified loc getLocalMLSClients getRemoteMLSClients getRemoteMLSClients :: - Member FederatorAccess r => + ( Member FederatorAccess r, + CallsFed 'Brig "get-mls-clients" + ) => Remote UserId -> SignatureSchemeTag -> Sem r (Set ClientInfo) diff --git a/services/galley/src/Galley/API/MLS/Propagate.hs b/services/galley/src/Galley/API/MLS/Propagate.hs index 74fbf8f608..22ca2d9d5e 100644 --- a/services/galley/src/Galley/API/MLS/Propagate.hs +++ b/services/galley/src/Galley/API/MLS/Propagate.hs @@ -52,7 +52,8 @@ propagateMessage :: Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, - Member TinyLog r + Member TinyLog r, + CallsFed 'Galley "on-mls-message-sent" ) => Qualified UserId -> Local Data.Conversation -> diff --git a/services/galley/src/Galley/API/MLS/Removal.hs b/services/galley/src/Galley/API/MLS/Removal.hs index f16edf2bd2..d06971da27 100644 --- a/services/galley/src/Galley/API/MLS/Removal.hs +++ b/services/galley/src/Galley/API/MLS/Removal.hs @@ -44,6 +44,7 @@ import Polysemy.Input import Polysemy.TinyLog import qualified System.Logger as Log import Wire.API.Conversation.Protocol +import Wire.API.Federation.API import Wire.API.MLS.KeyPackage import Wire.API.MLS.Message import Wire.API.MLS.Proposal @@ -61,7 +62,8 @@ removeClientsWithClientMap :: Input Env ] r, - Traversable t + Traversable t, + CallsFed 'Galley "on-mls-message-sent" ) => Local Data.Conversation -> t KeyPackageRef -> @@ -102,7 +104,8 @@ removeClient :: ProposalStore, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent" ) => Local Data.Conversation -> Qualified UserId -> @@ -128,7 +131,8 @@ removeUserWithClientMap :: ProposalStore, Input Env ] - r + r, + CallsFed 'Galley "on-mls-message-sent" ) => Local Data.Conversation -> ClientMap -> @@ -150,7 +154,8 @@ removeUser :: ProposalStore, TinyLog ] - r + r, + CallsFed 'Galley "on-mls-message-sent" ) => Local Data.Conversation -> Qualified UserId -> diff --git a/services/galley/src/Galley/API/MLS/Welcome.hs b/services/galley/src/Galley/API/MLS/Welcome.hs index 7e508e8f98..84c67b31f9 100644 --- a/services/galley/src/Galley/API/MLS/Welcome.hs +++ b/services/galley/src/Galley/API/MLS/Welcome.hs @@ -55,15 +55,17 @@ import Wire.API.MLS.Welcome import Wire.API.Message postMLSWelcome :: - Members - '[ BrigAccess, - FederatorAccess, - GundeckAccess, - ErrorS 'MLSKeyPackageRefNotFound, - Input UTCTime, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + FederatorAccess, + GundeckAccess, + ErrorS 'MLSKeyPackageRefNotFound, + Input UTCTime, + P.TinyLog + ] + r, + CallsFed 'Galley "mls-welcome" + ) => Local x -> Maybe ConnId -> RawMLS Welcome -> @@ -76,17 +78,19 @@ postMLSWelcome loc con wel = do sendRemoteWelcomes (rmRaw wel) remotes postMLSWelcomeFromLocalUser :: - Members - '[ BrigAccess, - FederatorAccess, - GundeckAccess, - ErrorS 'MLSKeyPackageRefNotFound, - ErrorS 'MLSNotEnabled, - Input UTCTime, - Input Env, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + FederatorAccess, + GundeckAccess, + ErrorS 'MLSKeyPackageRefNotFound, + ErrorS 'MLSNotEnabled, + Input UTCTime, + Input Env, + P.TinyLog + ] + r, + CallsFed 'Galley "mls-welcome" + ) => Local x -> ConnId -> RawMLS Welcome -> @@ -131,11 +135,13 @@ sendLocalWelcomes con now rawWelcome lclients = do in newMessagePush lclients mempty con defMessageMetadata (u, c) e sendRemoteWelcomes :: - Members - '[ FederatorAccess, - P.TinyLog - ] - r => + ( Members + '[ FederatorAccess, + P.TinyLog + ] + r, + CallsFed 'Galley "mls-welcome" + ) => ByteString -> [Remote (UserId, ClientId)] -> Sem r () diff --git a/services/galley/src/Galley/API/Message.hs b/services/galley/src/Galley/API/Message.hs index 94d6809ce5..8f0f2bb178 100644 --- a/services/galley/src/Galley/API/Message.hs +++ b/services/galley/src/Galley/API/Message.hs @@ -214,7 +214,7 @@ checkMessageClients sender participantMap recipientMap mismatchStrat = ) getRemoteClients :: - Member FederatorAccess r => + (Member FederatorAccess r, CallsFed 'Brig "get-user-clients") => [RemoteMember] -> Sem r (Map (Domain, UserId) (Set ClientId)) getRemoteClients remoteMembers = @@ -228,7 +228,7 @@ getRemoteClients remoteMembers = -- FUTUREWORK: sender should be Local UserId postRemoteOtrMessage :: - Members '[FederatorAccess] r => + (Members '[FederatorAccess] r, CallsFed 'Galley "send-message") => Qualified UserId -> Remote ConvId -> ByteString -> @@ -357,21 +357,24 @@ postBroadcast lusr con msg = runError $ do pure (mems ^. teamMembers) postQualifiedOtrMessage :: - Members - '[ BrigAccess, - ClientStore, - ConversationStore, - FederatorAccess, - GundeckAccess, - ExternalAccess, - Input (Local ()), -- FUTUREWORK: remove this - Input Opts, - Input UTCTime, - MemberStore, - TeamStore, - P.TinyLog - ] - r => + ( Members + '[ BrigAccess, + ClientStore, + ConversationStore, + FederatorAccess, + GundeckAccess, + ExternalAccess, + Input (Local ()), -- FUTUREWORK: remove this + Input Opts, + Input UTCTime, + MemberStore, + TeamStore, + P.TinyLog + ] + r, + CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" + ) => UserType -> Qualified UserId -> Maybe ConnId -> @@ -473,7 +476,8 @@ makeUserMap keys = (<> Map.fromSet (const mempty) keys) sendMessages :: forall t r. ( t ~ 'NormalMessage, - Members '[GundeckAccess, ExternalAccess, FederatorAccess, P.TinyLog] r + Members '[GundeckAccess, ExternalAccess, FederatorAccess, P.TinyLog] r, + CallsFed 'Galley "on-message-sent" ) => UTCTime -> Qualified UserId -> @@ -551,7 +555,7 @@ sendLocalMessages loc now sender senderClient mconn qcnv botMap metadata localMe sendRemoteMessages :: forall r x. - Members '[FederatorAccess, P.TinyLog] r => + (Members '[FederatorAccess, P.TinyLog] r, CallsFed 'Galley "on-message-sent") => Remote x -> UTCTime -> Qualified UserId -> diff --git a/services/galley/src/Galley/API/Public/Bot.hs b/services/galley/src/Galley/API/Public/Bot.hs index 8c75ddbdee..55e6bbaf09 100644 --- a/services/galley/src/Galley/API/Public/Bot.hs +++ b/services/galley/src/Galley/API/Public/Bot.hs @@ -19,8 +19,13 @@ module Galley.API.Public.Bot where import Galley.API.Update import Galley.App +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Bot -botAPI :: API BotAPI GalleyEffects +botAPI :: + ( CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" + ) => + API BotAPI GalleyEffects botAPI = mkNamedAPI @"post-bot-message-unqualified" postBotMessageUnqualified diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index f14ee73397..dbdc90591e 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -24,10 +24,22 @@ import Galley.API.Query import Galley.API.Update import Galley.App import Galley.Cassandra.TeamFeatures +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Conversation -conversationAPI :: API ConversationAPI GalleyEffects +conversationAPI :: + ( CallsFed 'Galley "get-conversations", + CallsFed 'Galley "query-group-info", + CallsFed 'Galley "on-typing-indicator-updated", + CallsFed 'Galley "on-conversation-created", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "leave-conversation", + CallsFed 'Galley "update-conversation" + ) => + API ConversationAPI GalleyEffects conversationAPI = mkNamedAPI @"get-unqualified-conversation" getUnqualifiedConversation <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" getUnqualifiedConversation diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 2d4f06ea85..5f2442aee5 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -22,11 +22,17 @@ import Galley.API.Teams.Features import Galley.App import Galley.Cassandra.TeamFeatures import Imports +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Feature import Wire.API.Team.Feature -featureAPI :: API FeatureAPI GalleyEffects +featureAPI :: + ( CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => + API FeatureAPI GalleyEffects featureAPI = mkNamedAPI @'("get", SSOConfig) (getFeatureStatus @Cassandra . DoAuth) <@> mkNamedAPI @'("get", LegalholdConfig) (getFeatureStatus @Cassandra . DoAuth) diff --git a/services/galley/src/Galley/API/Public/LegalHold.hs b/services/galley/src/Galley/API/Public/LegalHold.hs index 21d658d217..d0bfc6c41a 100644 --- a/services/galley/src/Galley/API/Public/LegalHold.hs +++ b/services/galley/src/Galley/API/Public/LegalHold.hs @@ -20,10 +20,16 @@ module Galley.API.Public.LegalHold where import Galley.API.LegalHold import Galley.App import Galley.Cassandra.TeamFeatures +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.LegalHold -legalHoldAPI :: API LegalHoldAPI GalleyEffects +legalHoldAPI :: + ( CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => + API LegalHoldAPI GalleyEffects legalHoldAPI = mkNamedAPI @"create-legal-hold-settings" (createSettings @Cassandra) <@> mkNamedAPI @"get-legal-hold-settings" (getSettings @Cassandra) diff --git a/services/galley/src/Galley/API/Public/MLS.hs b/services/galley/src/Galley/API/Public/MLS.hs index 93bd240b77..43261f9d0c 100644 --- a/services/galley/src/Galley/API/Public/MLS.hs +++ b/services/galley/src/Galley/API/Public/MLS.hs @@ -19,10 +19,20 @@ module Galley.API.Public.MLS where import Galley.API.MLS import Galley.App +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.MLS -mlsAPI :: API MLSAPI GalleyEffects +mlsAPI :: + ( CallsFed 'Galley "mls-welcome", + CallsFed 'Brig "get-mls-clients", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "send-mls-message", + CallsFed 'Galley "send-mls-commit-bundle" + ) => + API MLSAPI GalleyEffects mlsAPI = mkNamedAPI @"mls-welcome-message" postMLSWelcomeFromLocalUser <@> mkNamedAPI @"mls-message-v1" postMLSMessageFromLocalUserV1 diff --git a/services/galley/src/Galley/API/Public/Messaging.hs b/services/galley/src/Galley/API/Public/Messaging.hs index 806484ae90..29125fb011 100644 --- a/services/galley/src/Galley/API/Public/Messaging.hs +++ b/services/galley/src/Galley/API/Public/Messaging.hs @@ -19,10 +19,16 @@ module Galley.API.Public.Messaging where import Galley.API.Update import Galley.App +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Messaging -messagingAPI :: API MessagingAPI GalleyEffects +messagingAPI :: + ( CallsFed 'Brig "get-user-clients", + CallsFed 'Galley "on-message-sent", + CallsFed 'Galley "send-message" + ) => + API MessagingAPI GalleyEffects messagingAPI = mkNamedAPI @"post-otr-message-unqualified" postOtrMessageUnqualified <@> mkNamedAPI @"post-otr-broadcast-unqualified" postOtrBroadcastUnqualified diff --git a/services/galley/src/Galley/API/Public/Servant.hs b/services/galley/src/Galley/API/Public/Servant.hs index e7eae6adde..974063e151 100644 --- a/services/galley/src/Galley/API/Public/Servant.hs +++ b/services/galley/src/Galley/API/Public/Servant.hs @@ -28,10 +28,29 @@ import Galley.API.Public.Team import Galley.API.Public.TeamConversation import Galley.API.Public.TeamMember import Galley.App +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley -servantSitemap :: API ServantAPI GalleyEffects +servantSitemap :: + ( CallsFed 'Galley "get-conversations", + CallsFed 'Galley "leave-conversation", + CallsFed 'Galley "on-conversation-created", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Brig "get-user-clients", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-typing-indicator-updated", + CallsFed 'Galley "send-message", + CallsFed 'Brig "get-mls-clients", + CallsFed 'Galley "query-group-info", + CallsFed 'Galley "mls-welcome", + CallsFed 'Galley "update-conversation", + CallsFed 'Galley "send-mls-commit-bundle", + CallsFed 'Galley "send-mls-message" + ) => + API ServantAPI GalleyEffects servantSitemap = conversationAPI <@> teamConversationAPI diff --git a/services/galley/src/Galley/API/Public/TeamConversation.hs b/services/galley/src/Galley/API/Public/TeamConversation.hs index 359c69f1db..91abb7c0ad 100644 --- a/services/galley/src/Galley/API/Public/TeamConversation.hs +++ b/services/galley/src/Galley/API/Public/TeamConversation.hs @@ -19,10 +19,16 @@ module Galley.API.Public.TeamConversation where import Galley.API.Teams import Galley.App +import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamConversation -teamConversationAPI :: API TeamConversationAPI GalleyEffects +teamConversationAPI :: + ( CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => + API TeamConversationAPI GalleyEffects teamConversationAPI = mkNamedAPI @"get-team-conversation-roles" getTeamConversationRoles <@> mkNamedAPI @"get-team-conversations" getTeamConversations diff --git a/services/galley/src/Galley/API/Query.hs b/services/galley/src/Galley/API/Query.hs index 99fdf91f88..9d3be75637 100644 --- a/services/galley/src/Galley/API/Query.hs +++ b/services/galley/src/Galley/API/Query.hs @@ -141,16 +141,18 @@ getUnqualifiedConversation lusr cnv = do getConversation :: forall r. - Members - '[ ConversationStore, - ErrorS 'ConvNotFound, - ErrorS 'ConvAccessDenied, - Error FederationError, - Error InternalError, - FederatorAccess, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + Error FederationError, + Error InternalError, + FederatorAccess, + P.TinyLog + ] + r, + CallsFed 'Galley "get-conversations" + ) => Local UserId -> Qualified ConvId -> Sem r Public.Conversation @@ -171,14 +173,16 @@ getConversation lusr cnv = do _convs -> throw $ FederationUnexpectedBody "expected one conversation, got multiple" getRemoteConversations :: - Members - '[ ConversationStore, - Error FederationError, - ErrorS 'ConvNotFound, - FederatorAccess, - P.TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error FederationError, + ErrorS 'ConvNotFound, + FederatorAccess, + P.TinyLog + ] + r, + CallsFed 'Galley "get-conversations" + ) => Local UserId -> [Remote ConvId] -> Sem r [Public.Conversation] @@ -224,7 +228,9 @@ partitionGetConversationFailures = bimap concat concat . partitionEithers . map split (FailedGetConversation convs (FailedGetConversationRemotely _)) = Right convs getRemoteConversationsWithFailures :: - Members '[ConversationStore, FederatorAccess, P.TinyLog] r => + ( Members '[ConversationStore, FederatorAccess, P.TinyLog] r, + CallsFed 'Galley "get-conversations" + ) => Local UserId -> [Remote ConvId] -> Sem r ([FailedGetConversation], [Public.Conversation]) @@ -476,7 +482,7 @@ getConversationsInternal luser mids mstart msize = do | otherwise = pure True listConversations :: - Members '[ConversationStore, Error InternalError, FederatorAccess, P.TinyLog] r => + (Members '[ConversationStore, Error InternalError, FederatorAccess, P.TinyLog] r, CallsFed 'Galley "get-conversations") => Local UserId -> Public.ListConversations -> Sem r Public.ConversationsResponse diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index d4c7da58ed..20197ae4fc 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -132,6 +132,7 @@ import Wire.API.Error import Wire.API.Error.Galley import qualified Wire.API.Event.Conversation as Conv import Wire.API.Event.Team +import Wire.API.Federation.API import Wire.API.Federation.Error import qualified Wire.API.Message as Conv import qualified Wire.API.Notification as Public @@ -1101,23 +1102,27 @@ getTeamConversation zusr tid cid = do >>= noteS @'ConvNotFound deleteTeamConversation :: - Members - '[ CodeStore, - ConversationStore, - Error FederationError, - Error InvalidInput, - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ErrorS 'NotATeamMember, - ErrorS ('ActionDenied 'DeleteConversation), - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - TeamStore - ] - r => + ( Members + '[ CodeStore, + ConversationStore, + Error FederationError, + Error InvalidInput, + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ErrorS 'NotATeamMember, + ErrorS ('ActionDenied 'DeleteConversation), + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> TeamId -> diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index 4556f935d7..be179c9c99 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -76,6 +76,7 @@ import Wire.API.Conversation.Role (Action (RemoveConversationMember)) import Wire.API.Error (ErrorS, throwS) import Wire.API.Error.Galley import qualified Wire.API.Event.FeatureConfig as Event +import Wire.API.Federation.API import qualified Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti as Multi import Wire.API.Team.Feature import Wire.API.Team.Member @@ -707,7 +708,13 @@ instance GetFeatureConfig db LegalholdConfig where False -> FeatureStatusDisabled pure $ setStatus status defFeatureStatus -instance SetFeatureConfig db LegalholdConfig where +instance + ( CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => + SetFeatureConfig db LegalholdConfig + where type SetConfigForTeamConstraints db LegalholdConfig (r :: EffectRow) = ( Bounded (PagingBounds InternalPaging TeamMember), diff --git a/services/galley/src/Galley/API/Update.hs b/services/galley/src/Galley/API/Update.hs index ce1f36980b..1065f4f92e 100644 --- a/services/galley/src/Galley/API/Update.hs +++ b/services/galley/src/Galley/API/Update.hs @@ -290,7 +290,11 @@ type UpdateConversationAccessEffects = ] updateConversationAccess :: - Members UpdateConversationAccessEffects r => + ( Members UpdateConversationAccessEffects r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-conversation-updated" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -302,7 +306,11 @@ updateConversationAccess lusr con qcnv update = do updateLocalConversation @'ConversationAccessDataTag lcnv (tUntagged lusr) (Just con) update updateConversationAccessUnqualified :: - Members UpdateConversationAccessEffects r => + ( Members UpdateConversationAccessEffects r, + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "on-conversation-updated" + ) => Local UserId -> ConnId -> ConvId -> @@ -317,23 +325,28 @@ updateConversationAccessUnqualified lusr con cnv update = update updateConversationReceiptMode :: - Members - '[ BrigAccess, - ConversationStore, - Error FederationError, - ErrorS ('ActionDenied 'ModifyConversationReceiptMode), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input (Local ()), - Input Env, - Input UTCTime, - MemberStore, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error FederationError, + ErrorS ('ActionDenied 'ModifyConversationReceiptMode), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input (Local ()), + Input Env, + Input UTCTime, + MemberStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "update-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -369,7 +382,8 @@ updateRemoteConversation :: r, Members (HasConversationActionGalleyErrors tag) r, RethrowErrors (HasConversationActionGalleyErrors tag) (Error NoChanges : r), - SingI tag + SingI tag, + CallsFed 'Galley "update-conversation" ) => Remote ConvId -> Local UserId -> @@ -393,23 +407,28 @@ updateRemoteConversation rcnv lusr conn action = getUpdateResult $ do notifyRemoteConversationAction lusr (qualifyAs rcnv convUpdate) (Just conn) updateConversationReceiptModeUnqualified :: - Members - '[ BrigAccess, - ConversationStore, - Error FederationError, - ErrorS ('ActionDenied 'ModifyConversationReceiptMode), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input (Local ()), - Input Env, - Input UTCTime, - MemberStore, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error FederationError, + ErrorS ('ActionDenied 'ModifyConversationReceiptMode), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input (Local ()), + Input Env, + Input UTCTime, + MemberStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation", + CallsFed 'Galley "update-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -418,19 +437,23 @@ updateConversationReceiptModeUnqualified :: updateConversationReceiptModeUnqualified lusr zcon cnv = updateConversationReceiptMode lusr zcon (tUntagged (qualifyAs lusr cnv)) updateConversationMessageTimer :: - Members - '[ ConversationStore, - ErrorS ('ActionDenied 'ModifyConversationMessageTimer), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - Error FederationError, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime - ] - r => + ( Members + '[ ConversationStore, + ErrorS ('ActionDenied 'ModifyConversationMessageTimer), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + Error FederationError, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -453,19 +476,23 @@ updateConversationMessageTimer lusr zcon qcnv update = qcnv updateConversationMessageTimerUnqualified :: - Members - '[ ConversationStore, - ErrorS ('ActionDenied 'ModifyConversationMessageTimer), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - Error FederationError, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime - ] - r => + ( Members + '[ ConversationStore, + ErrorS ('ActionDenied 'ModifyConversationMessageTimer), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + Error FederationError, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -474,22 +501,26 @@ updateConversationMessageTimerUnqualified :: updateConversationMessageTimerUnqualified lusr zcon cnv = updateConversationMessageTimer lusr zcon (tUntagged (qualifyAs lusr cnv)) deleteLocalConversation :: - Members - '[ CodeStore, - ConversationStore, - Error FederationError, - ErrorS 'NotATeamMember, - ErrorS ('ActionDenied 'DeleteConversation), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - TeamStore - ] - r => + ( Members + '[ CodeStore, + ConversationStore, + Error FederationError, + ErrorS 'NotATeamMember, + ErrorS ('ActionDenied 'DeleteConversation), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + TeamStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Local ConvId -> @@ -697,7 +728,9 @@ joinConversationByReusableCode :: TeamFeatureStore db ] r, - FeaturePersistentConstraint db GuestLinksConfig + FeaturePersistentConstraint db GuestLinksConfig, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" ) => Local UserId -> ConnId -> @@ -728,7 +761,9 @@ joinConversationById :: TeamStore, TeamFeatureStore db ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" ) => Local UserId -> ConnId -> @@ -739,23 +774,26 @@ joinConversationById lusr zcon cnv = do joinConversation @db lusr zcon conv LinkAccess joinConversation :: - Members - '[ BrigAccess, - ConversationStore, - FederatorAccess, - ErrorS 'ConvAccessDenied, - ErrorS 'InvalidOperation, - ErrorS 'NotATeamMember, - ErrorS 'TooManyMembers, - ExternalAccess, - GundeckAccess, - Input Opts, - Input UTCTime, - MemberStore, - TeamStore, - TeamFeatureStore db - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + FederatorAccess, + ErrorS 'ConvAccessDenied, + ErrorS 'InvalidOperation, + ErrorS 'NotATeamMember, + ErrorS 'TooManyMembers, + ExternalAccess, + GundeckAccess, + Input Opts, + Input UTCTime, + MemberStore, + TeamStore, + TeamFeatureStore db + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Data.Conversation -> @@ -785,33 +823,37 @@ joinConversation lusr zcon conv access = do action addMembers :: - Members - '[ BrigAccess, - ConversationStore, - Error FederationError, - Error InternalError, - ErrorS ('ActionDenied 'AddConversationMember), - ErrorS ('ActionDenied 'LeaveConversation), - ErrorS 'ConvAccessDenied, - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ErrorS 'NotConnected, - ErrorS 'NotATeamMember, - ErrorS 'TooManyMembers, - ErrorS 'MissingLegalholdConsent, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input Opts, - Input UTCTime, - LegalHoldStore, - MemberStore, - ProposalStore, - TeamStore, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error FederationError, + Error InternalError, + ErrorS ('ActionDenied 'AddConversationMember), + ErrorS ('ActionDenied 'LeaveConversation), + ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ErrorS 'NotConnected, + ErrorS 'NotATeamMember, + ErrorS 'TooManyMembers, + ErrorS 'MissingLegalholdConsent, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input Opts, + Input UTCTime, + LegalHoldStore, + MemberStore, + ProposalStore, + TeamStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -824,33 +866,37 @@ addMembers lusr zcon qcnv (InviteQualified users role) = do ConversationJoin users role addMembersUnqualifiedV2 :: - Members - '[ BrigAccess, - ConversationStore, - Error FederationError, - Error InternalError, - ErrorS ('ActionDenied 'AddConversationMember), - ErrorS ('ActionDenied 'LeaveConversation), - ErrorS 'ConvAccessDenied, - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ErrorS 'NotConnected, - ErrorS 'NotATeamMember, - ErrorS 'TooManyMembers, - ErrorS 'MissingLegalholdConsent, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input Opts, - Input UTCTime, - LegalHoldStore, - MemberStore, - ProposalStore, - TeamStore, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error FederationError, + Error InternalError, + ErrorS ('ActionDenied 'AddConversationMember), + ErrorS ('ActionDenied 'LeaveConversation), + ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ErrorS 'NotConnected, + ErrorS 'NotATeamMember, + ErrorS 'TooManyMembers, + ErrorS 'MissingLegalholdConsent, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input Opts, + Input UTCTime, + LegalHoldStore, + MemberStore, + ProposalStore, + TeamStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -863,33 +909,37 @@ addMembersUnqualifiedV2 lusr zcon cnv (InviteQualified users role) = do ConversationJoin users role addMembersUnqualified :: - Members - '[ BrigAccess, - ConversationStore, - Error FederationError, - Error InternalError, - ErrorS ('ActionDenied 'AddConversationMember), - ErrorS ('ActionDenied 'LeaveConversation), - ErrorS 'ConvAccessDenied, - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ErrorS 'NotConnected, - ErrorS 'NotATeamMember, - ErrorS 'TooManyMembers, - ErrorS 'MissingLegalholdConsent, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input Opts, - Input UTCTime, - LegalHoldStore, - MemberStore, - ProposalStore, - TeamStore, - TinyLog - ] - r => + ( Members + '[ BrigAccess, + ConversationStore, + Error FederationError, + Error InternalError, + ErrorS ('ActionDenied 'AddConversationMember), + ErrorS ('ActionDenied 'LeaveConversation), + ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ErrorS 'NotConnected, + ErrorS 'NotATeamMember, + ErrorS 'TooManyMembers, + ErrorS 'MissingLegalholdConsent, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input Opts, + Input UTCTime, + LegalHoldStore, + MemberStore, + ProposalStore, + TeamStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -968,21 +1018,25 @@ updateUnqualifiedSelfMember lusr zcon cnv update = do updateSelfMember lusr zcon (tUntagged lcnv) update updateOtherMemberLocalConv :: - Members - '[ ConversationStore, - ErrorS ('ActionDenied 'ModifyOtherConversationMember), - ErrorS 'InvalidTarget, - ErrorS 'InvalidOperation, - ErrorS 'ConvNotFound, - ErrorS 'ConvMemberNotFound, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore - ] - r => + ( Members + '[ ConversationStore, + ErrorS ('ActionDenied 'ModifyOtherConversationMember), + ErrorS 'InvalidTarget, + ErrorS 'InvalidOperation, + ErrorS 'ConvNotFound, + ErrorS 'ConvMemberNotFound, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local ConvId -> Local UserId -> ConnId -> @@ -996,21 +1050,25 @@ updateOtherMemberLocalConv lcnv lusr con qvictim update = void . getUpdateResult ConversationMemberUpdate qvictim update updateOtherMemberUnqualified :: - Members - '[ ConversationStore, - ErrorS ('ActionDenied 'ModifyOtherConversationMember), - ErrorS 'InvalidTarget, - ErrorS 'InvalidOperation, - ErrorS 'ConvNotFound, - ErrorS 'ConvMemberNotFound, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore - ] - r => + ( Members + '[ ConversationStore, + ErrorS ('ActionDenied 'ModifyOtherConversationMember), + ErrorS 'InvalidTarget, + ErrorS 'InvalidOperation, + ErrorS 'ConvNotFound, + ErrorS 'ConvMemberNotFound, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -1023,22 +1081,26 @@ updateOtherMemberUnqualified lusr zcon cnv victim update = do updateOtherMemberLocalConv lcnv lusr zcon (tUntagged lvictim) update updateOtherMember :: - Members - '[ ConversationStore, - Error FederationError, - ErrorS ('ActionDenied 'ModifyOtherConversationMember), - ErrorS 'InvalidTarget, - ErrorS 'InvalidOperation, - ErrorS 'ConvNotFound, - ErrorS 'ConvMemberNotFound, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore - ] - r => + ( Members + '[ ConversationStore, + Error FederationError, + ErrorS ('ActionDenied 'ModifyOtherConversationMember), + ErrorS 'InvalidTarget, + ErrorS 'InvalidOperation, + ErrorS 'ConvNotFound, + ErrorS 'ConvMemberNotFound, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -1060,22 +1122,27 @@ updateOtherMemberRemoteConv :: updateOtherMemberRemoteConv _ _ _ _ _ = throw FederationNotImplemented removeMemberUnqualified :: - Members - '[ ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore, - ProposalStore, - TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore, + ProposalStore, + TinyLog + ] + r, + CallsFed 'Galley "leave-conversation", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -1087,22 +1154,27 @@ removeMemberUnqualified lusr con cnv victim = do removeMemberQualified lusr con (tUntagged lcnv) (tUntagged lvictim) removeMemberQualified :: - Members - '[ ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore, - ProposalStore, - TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore, + ProposalStore, + TinyLog + ] + r, + CallsFed 'Galley "leave-conversation", + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -1118,13 +1190,15 @@ removeMemberQualified lusr con qcnv victim = victim removeMemberFromRemoteConv :: - Members - '[ FederatorAccess, - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'ConvNotFound, - Input UTCTime - ] - r => + ( Members + '[ FederatorAccess, + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'ConvNotFound, + Input UTCTime + ] + r, + CallsFed 'Galley "leave-conversation" + ) => Remote ConvId -> Local UserId -> Qualified UserId -> @@ -1155,23 +1229,27 @@ removeMemberFromRemoteConv cnv lusr victim -- | Remove a member from a local conversation. removeMemberFromLocalConv :: - Members - '[ ConversationStore, - Error InternalError, - ErrorS ('ActionDenied 'LeaveConversation), - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime, - MemberStore, - ProposalStore, - TinyLog - ] - r => + ( Members + '[ ConversationStore, + Error InternalError, + ErrorS ('ActionDenied 'LeaveConversation), + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore, + ProposalStore, + TinyLog + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local ConvId -> Local UserId -> Maybe ConnId -> @@ -1193,21 +1271,25 @@ removeMemberFromLocalConv lcnv lusr con victim -- OTR postProteusMessage :: - Members - '[ BotAccess, - BrigAccess, - ClientStore, - ConversationStore, - FederatorAccess, - GundeckAccess, - ExternalAccess, - Input Opts, - Input UTCTime, - MemberStore, - TeamStore, - TinyLog - ] - r => + ( Members + '[ BotAccess, + BrigAccess, + ClientStore, + ConversationStore, + FederatorAccess, + GundeckAccess, + ExternalAccess, + Input Opts, + Input UTCTime, + MemberStore, + TeamStore, + TinyLog + ] + r, + CallsFed 'Brig "get-user-clients", + CallsFed 'Galley "on-message-sent", + CallsFed 'Galley "send-message" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -1292,7 +1374,9 @@ postBotMessageUnqualified :: TinyLog, Input UTCTime ] - r + r, + CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" ) => BotId -> ConvId -> @@ -1336,21 +1420,24 @@ postOtrBroadcastUnqualified sender zcon = (postBroadcast sender (Just zcon)) postOtrMessageUnqualified :: - Members - '[ BotAccess, - BrigAccess, - ClientStore, - ConversationStore, - FederatorAccess, - GundeckAccess, - ExternalAccess, - MemberStore, - Input Opts, - Input UTCTime, - TeamStore, - TinyLog - ] - r => + ( Members + '[ BotAccess, + BrigAccess, + ClientStore, + ConversationStore, + FederatorAccess, + GundeckAccess, + ExternalAccess, + MemberStore, + Input Opts, + Input UTCTime, + TeamStore, + TinyLog + ] + r, + CallsFed 'Galley "on-message-sent", + CallsFed 'Brig "get-user-clients" + ) => Local UserId -> ConnId -> ConvId -> @@ -1365,20 +1452,24 @@ postOtrMessageUnqualified sender zcon cnv = (runLocalInput sender . postQualifiedOtrMessage User (tUntagged sender) (Just zcon) lcnv) updateConversationName :: - Members - '[ ConversationStore, - Error FederationError, - Error InvalidInput, - ErrorS ('ActionDenied 'ModifyConversationName), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime - ] - r => + ( Members + '[ ConversationStore, + Error FederationError, + Error InvalidInput, + ErrorS ('ActionDenied 'ModifyConversationName), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Qualified ConvId -> @@ -1393,19 +1484,23 @@ updateConversationName lusr zcon qcnv convRename = do convRename updateUnqualifiedConversationName :: - Members - '[ ConversationStore, - Error InvalidInput, - ErrorS ('ActionDenied 'ModifyConversationName), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime - ] - r => + ( Members + '[ ConversationStore, + Error InvalidInput, + ErrorS ('ActionDenied 'ModifyConversationName), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> ConvId -> @@ -1416,19 +1511,23 @@ updateUnqualifiedConversationName lusr zcon cnv rename = do updateLocalConversationName lusr zcon lcnv rename updateLocalConversationName :: - Members - '[ ConversationStore, - Error InvalidInput, - ErrorS ('ActionDenied 'ModifyConversationName), - ErrorS 'ConvNotFound, - ErrorS 'InvalidOperation, - ExternalAccess, - FederatorAccess, - GundeckAccess, - Input Env, - Input UTCTime - ] - r => + ( Members + '[ ConversationStore, + Error InvalidInput, + ErrorS ('ActionDenied 'ModifyConversationName), + ErrorS 'ConvNotFound, + ErrorS 'InvalidOperation, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime + ] + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-mls-message-sent", + CallsFed 'Galley "on-new-remote-conversation" + ) => Local UserId -> ConnId -> Local ConvId -> @@ -1439,16 +1538,18 @@ updateLocalConversationName lusr zcon lcnv rename = updateLocalConversation @'ConversationRenameTag lcnv (tUntagged lusr) (Just zcon) rename isTypingQualified :: - Members - '[ GundeckAccess, - ErrorS 'ConvNotFound, - Input (Local ()), - Input UTCTime, - MemberStore, - FederatorAccess, - WaiRoutes - ] - r => + ( Members + '[ GundeckAccess, + ErrorS 'ConvNotFound, + Input (Local ()), + Input UTCTime, + MemberStore, + FederatorAccess, + WaiRoutes + ] + r, + CallsFed 'Galley "on-typing-indicator-updated" + ) => Local UserId -> ConnId -> Qualified ConvId -> diff --git a/services/galley/src/Galley/API/Util.hs b/services/galley/src/Galley/API/Util.hs index 3d16bd5d25..c31f30bc19 100644 --- a/services/galley/src/Galley/API/Util.hs +++ b/services/galley/src/Galley/API/Util.hs @@ -721,7 +721,7 @@ fromConversationCreated loc rc@ConversationCreated {..} = -- | Notify remote users of being added to a new conversation registerRemoteConversationMemberships :: - Member FederatorAccess r => + (Member FederatorAccess r, CallsFed 'Galley "on-conversation-created") => -- | The time stamp when the conversation was created UTCTime -> -- | The domain of the user that created the conversation From 7a96592a893ccdc953f5c052856bff05b043c4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Dimja=C5=A1evi=C4=87?= Date: Wed, 28 Dec 2022 10:59:45 +0100 Subject: [PATCH 03/33] Remove an unused effect for remote conversation listing (#2954) * Remove a non-used module --- .../rm-unused-remote-conv-list-store-effect | 1 + services/galley/galley.cabal | 1 - .../Effects/RemoteConversationListStore.hs | 45 ------------------- 3 files changed, 1 insertion(+), 46 deletions(-) create mode 100644 changelog.d/5-internal/rm-unused-remote-conv-list-store-effect delete mode 100644 services/galley/src/Galley/Effects/RemoteConversationListStore.hs diff --git a/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect b/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect new file mode 100644 index 0000000000..eb2d1ade2c --- /dev/null +++ b/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect @@ -0,0 +1 @@ +Remove an unused effect for remote conversation listing diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 69cb442338..0c1cac4e69 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -104,7 +104,6 @@ library Galley.Effects.MemberStore Galley.Effects.ProposalStore Galley.Effects.Queue - Galley.Effects.RemoteConversationListStore Galley.Effects.SearchVisibilityStore Galley.Effects.ServiceStore Galley.Effects.SparAccess diff --git a/services/galley/src/Galley/Effects/RemoteConversationListStore.hs b/services/galley/src/Galley/Effects/RemoteConversationListStore.hs deleted file mode 100644 index 54a076818a..0000000000 --- a/services/galley/src/Galley/Effects/RemoteConversationListStore.hs +++ /dev/null @@ -1,45 +0,0 @@ -{-# LANGUAGE TemplateHaskell #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Galley.Effects.RemoteConversationListStore - ( RemoteConversationListStore (..), - listRemoteConversations, - getRemoteConversationStatus, - ) -where - -import Data.Id -import Data.Qualified -import Galley.Types.Conversations.Members -import Imports -import Polysemy -import Wire.Sem.Paging - -data RemoteConversationListStore p m a where - ListRemoteConversations :: - UserId -> - Maybe (PagingState p (Remote ConvId)) -> - Int32 -> - RemoteConversationListStore p m (Page p (Remote ConvId)) - GetRemoteConversationStatus :: - UserId -> - [Remote ConvId] -> - RemoteConversationListStore p m (Map (Remote ConvId) MemberStatus) - -makeSem ''RemoteConversationListStore From 3f9c17e9ce1c06e20d2956076c7b2d93201ccd3e Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 28 Dec 2022 11:03:02 +0100 Subject: [PATCH 04/33] hack/bin/upload-image: Retry despite `set -e` (#2953) Executing `"$@"` within first argument of `if` prevents `set -e` from immediately failing the whole script. It could also be written as `while ! "$@"; do ...`, but then getting status of `"$@"` is more complicated as `! "$@"` has status=0 and overwrites the value of `$?`. --- hack/bin/upload-image.sh | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/hack/bin/upload-image.sh b/hack/bin/upload-image.sh index e49eaca08b..a070b8661b 100755 --- a/hack/bin/upload-image.sh +++ b/hack/bin/upload-image.sh @@ -35,29 +35,24 @@ function retry { local maxAttempts=$1 local secondsDelay=1 local attemptCount=1 - local output= shift 1 while [ $attemptCount -le "$maxAttempts" ]; do - output=$("$@") - local status=$? - - if [ $status -eq 0 ]; then + if "$@"; then break - fi - - if [ $attemptCount -lt "$maxAttempts" ]; then - echo "Command [$*] failed after attempt $attemptCount of $maxAttempts. Retrying in $secondsDelay second(s)." >&2 - sleep $secondsDelay - elif [ $attemptCount -eq "$maxAttempts" ]; then - echo "Command [$*] failed after $attemptCount attempt(s)" >&2 - return $status + else + local status=$? + if [ $attemptCount -lt "$maxAttempts" ]; then + echo "Command [$*] failed after attempt $attemptCount of $maxAttempts. Retrying in $secondsDelay second(s)." >&2 + sleep $secondsDelay + elif [ $attemptCount -eq "$maxAttempts" ]; then + echo "Command [$*] failed after $attemptCount attempt(s)" >&2 + return $status + fi fi attemptCount=$((attemptCount + 1)) secondsDelay=$((secondsDelay * 2)) done - - echo "$output" } tmp_link_store=$(mktemp -d) From 0b9988991774ef2c56eaa5ec94d6a65194a6bf3b Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Thu, 29 Dec 2022 05:53:55 +0100 Subject: [PATCH 05/33] Allow using vhost style addressing for S3 (#2955) Path style is not supported for newer buckets, more info: https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ All object storage providers (like MinIO, ScalityRing, etc) might not work with vhost style addressing, so this change introduces a new configuration option in cargohold as aws.s3AddressingStyle to choose the addressing style. Other changes: * Move wire-server from using forked version of amazonka to upstream HEAD. The option to choose S3 Addressing Style has been implemented in https://github.com/brendanhay/amazonka/pull/832 * Makefile: Skip schema migrations for packages without DB This allows running something like `make ci package=cargohold` even if cargohold doesn't produce a cargohold-schema executable. --- Makefile | 5 +- .../2-features/vhost-addressing-for-s3 | 3 ++ charts/cargohold/templates/configmap.yaml | 3 ++ .../how-to/install/configuration-options.rst | 46 ++++++++++++++++++ libs/types-common-aws/default.nix | 2 + libs/types-common-aws/src/AWS/Util.hs | 5 +- libs/types-common-aws/types-common-aws.cabal | 1 + nix/haskell-pins.nix | 6 +-- services/brig/brig.cabal | 9 ++-- services/brig/default.nix | 2 + services/brig/src/Brig/AWS.hs | 17 +++---- services/brig/src/Brig/Data/Client.hs | 3 +- services/cargohold/src/CargoHold/AWS.hs | 16 ++++--- services/cargohold/src/CargoHold/App.hs | 4 +- services/cargohold/src/CargoHold/Options.hs | 41 ++++++++++++++++ services/cargohold/src/CargoHold/S3.hs | 4 +- services/galley/src/Galley/Aws.hs | 10 ++-- services/gundeck/default.nix | 2 + services/gundeck/gundeck.cabal | 7 +-- services/gundeck/src/Gundeck/Aws.hs | 47 ++++++++++--------- 20 files changed, 173 insertions(+), 60 deletions(-) create mode 100644 changelog.d/2-features/vhost-addressing-for-s3 diff --git a/Makefile b/Makefile index f035d080cc..dab64d021e 100644 --- a/Makefile +++ b/Makefile @@ -266,8 +266,11 @@ ifeq ($(package), all) ./dist/galley-schema --keyspace galley_test --replication-factor 1 ./dist/gundeck-schema --keyspace gundeck_test --replication-factor 1 ./dist/spar-schema --keyspace spar_test --replication-factor 1 -else +# How this check works: https://stackoverflow.com/a/9802777 +else ifeq ($(package), $(filter $(package),brig galley gundeck spar)) $(EXE_SCHEMA) --keyspace $(package)_test --replication-factor 1 +else + @echo No schema migrations for $(package) endif diff --git a/changelog.d/2-features/vhost-addressing-for-s3 b/changelog.d/2-features/vhost-addressing-for-s3 new file mode 100644 index 0000000000..aca06463e2 --- /dev/null +++ b/changelog.d/2-features/vhost-addressing-for-s3 @@ -0,0 +1,3 @@ +Allow vhost style addressing for S3 as path style is not supported for newer buckets. + +More info: https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ \ No newline at end of file diff --git a/charts/cargohold/templates/configmap.yaml b/charts/cargohold/templates/configmap.yaml index 5ceadf367c..942185bda3 100644 --- a/charts/cargohold/templates/configmap.yaml +++ b/charts/cargohold/templates/configmap.yaml @@ -28,6 +28,9 @@ data: {{- if .s3Compatibility }} s3Compatibility: {{ .s3Compatibility }} {{- end }} + {{- if .s3AddressingStyle }} + s3AddressingStyle: {{ .s3AddressingStyle }} + {{- end }} {{ if .cloudFront }} cloudFront: domain: {{ .cloudFront.domain }} diff --git a/docs/src/how-to/install/configuration-options.rst b/docs/src/how-to/install/configuration-options.rst index 94a6136cc6..869dc6c603 100644 --- a/docs/src/how-to/install/configuration-options.rst +++ b/docs/src/how-to/install/configuration-options.rst @@ -1058,3 +1058,49 @@ The table assumes the following: * When backend level config says that this feature is disabled, the list of domains is ignored. * When team level feature is disabled, the accompanying domains are ignored. +S3 Addressing Style +------------------- + +S3 can either by addressed in path style, i.e. +`https:////`, or vhost style, i.e. +`https://./`. AWS's S3 offering has deprecated +path style addressing for S3 and completely disabled it for buckets created +after 30 Sep 2020: +https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ + +However other object storage providers (specially self-deployed ones like MinIO) +may not support vhost style addressing yet (or ever?). Users of such buckets +should configure this option to "path": + +.. code:: yaml + + cargohold: + aws: + s3AddressingStyle: path + +Installations using S3 service provided by AWS, should use "auto", this option +will ensure that vhost style is only used when it is possible to construct a +valid hostname from the bucket name and the bucket name doesn't contain a '.'. +Having a '.' in the bucket name causes TLS validation to fail, hence it is not +used by default: + +.. code:: yaml + + cargohold: + aws: + s3AddressingStyle: auto + + +Using "virtual" as an option is only useful in situations where vhost style +addressing must be used even if it is not possible to construct a valid hostname +from the bucket name or the S3 service provider can ensure correct certificate +is issued for bucket which contain one or more '.'s in the name: + +.. code:: yaml + + cargohold: + aws: + s3AddressingStyle: virtual + +When this option is unspecified, wire-server defaults to path style addressing +to ensure smooth transition for older deployments. diff --git a/libs/types-common-aws/default.nix b/libs/types-common-aws/default.nix index 647dd6884d..a296d9c9d8 100644 --- a/libs/types-common-aws/default.nix +++ b/libs/types-common-aws/default.nix @@ -4,6 +4,7 @@ # dependencies are added or removed. { mkDerivation , amazonka +, amazonka-core , amazonka-sqs , base , base64-bytestring @@ -27,6 +28,7 @@ mkDerivation { src = gitignoreSource ./.; libraryHaskellDepends = [ amazonka + amazonka-core amazonka-sqs base base64-bytestring diff --git a/libs/types-common-aws/src/AWS/Util.hs b/libs/types-common-aws/src/AWS/Util.hs index 1eff3fe67e..a2a2a0055c 100644 --- a/libs/types-common-aws/src/AWS/Util.hs +++ b/libs/types-common-aws/src/AWS/Util.hs @@ -18,15 +18,16 @@ module AWS.Util where import qualified Amazonka as AWS +import qualified Amazonka.Data.Time as AWS import Data.Time import Imports readAuthExpiration :: AWS.Env -> IO (Maybe NominalDiffTime) readAuthExpiration env = do authEnv <- - case runIdentity (AWS.envAuth env) of + case runIdentity (AWS.auth env) of AWS.Auth authEnv -> pure authEnv AWS.Ref _ ref -> do readIORef ref now <- getCurrentTime - pure $ (`diffUTCTime` now) . AWS.fromTime <$> AWS._authExpiration authEnv + pure $ (`diffUTCTime` now) . AWS.fromTime <$> AWS.expiration authEnv diff --git a/libs/types-common-aws/types-common-aws.cabal b/libs/types-common-aws/types-common-aws.cabal index 7d8813a2b7..120d78603f 100644 --- a/libs/types-common-aws/types-common-aws.cabal +++ b/libs/types-common-aws/types-common-aws.cabal @@ -75,6 +75,7 @@ library ghc-prof-options: -fprof-auto-exported build-depends: amazonka + , amazonka-core , amazonka-sqs , base >=4 && <5 , base64-bytestring >=1.0 diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index b228bb1688..f097910d74 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -93,9 +93,9 @@ let }; amazonka = { src = fetchgit { - url = "https://github.com/wireapp/amazonka"; - rev = "7ced54b0396296307b9871d293cc0ac161e5743d"; - sha256 = "0md658m32zrvzc8nljn58r8iw4rqxpihgdnqrhl8vnmkq6i9np51"; + url = "https://github.com/brendanhay/amazonka"; + rev = "cfe2584aef0b03c86650372d362c74f237925d8c"; + sha256 = "sha256-ss8IuIN0BbS6LMjlaFmUdxUqQu+IHsA8ucsjxXJwbyg="; }; packages = { amazonka = "lib/amazonka"; diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 09e7f1aa14..4377d9f20c 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -183,10 +183,11 @@ library build-depends: aeson >=2.0.1.0 - , amazonka >=1.3.7 - , amazonka-dynamodb >=1.3.7 - , amazonka-ses >=1.3.7 - , amazonka-sqs >=1.3.7 + , amazonka >=2 + , amazonka-core >=2 + , amazonka-dynamodb >=2 + , amazonka-ses >=2 + , amazonka-sqs >=2 , async >=2.1 , attoparsec >=0.12 , auto-update >=0.1 diff --git a/services/brig/default.nix b/services/brig/default.nix index b67ec42e53..6c98c83546 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -5,6 +5,7 @@ { mkDerivation , aeson , amazonka +, amazonka-core , amazonka-dynamodb , amazonka-ses , amazonka-sqs @@ -168,6 +169,7 @@ mkDerivation { libraryHaskellDepends = [ aeson amazonka + amazonka-core amazonka-dynamodb amazonka-ses amazonka-sqs diff --git a/services/brig/src/Brig/AWS.hs b/services/brig/src/Brig/AWS.hs index 1f9a88dbe1..1faee37564 100644 --- a/services/brig/src/Brig/AWS.hs +++ b/services/brig/src/Brig/AWS.hs @@ -47,6 +47,7 @@ where import Amazonka (AWSRequest, AWSResponse) import qualified Amazonka as AWS +import qualified Amazonka.Data.Text as AWS import qualified Amazonka.DynamoDB as DDB import qualified Amazonka.SES as SES import qualified Amazonka.SES.Lens as SES @@ -122,13 +123,13 @@ mkEnv lgr opts emailOpts mgr = do mkAwsEnv g ses dyn sqs = do baseEnv <- AWS.newEnv AWS.discover - <&> maybe id AWS.configure ses - <&> maybe id AWS.configure dyn - <&> AWS.configure sqs + <&> maybe id AWS.configureService ses + <&> maybe id AWS.configureService dyn + <&> AWS.configureService sqs pure $ baseEnv - { AWS.envLogger = awsLogger g, - AWS.envManager = mgr + { AWS.logger = awsLogger g, + AWS.manager = mgr } awsLogger g l = Logger.log g (mapLevel l) . Logger.msg . toLazyByteString mapLevel AWS.Info = Logger.Info @@ -226,10 +227,10 @@ sendMail m = do -- after the fact. AWS.ServiceError se | se - ^. AWS.serviceStatus + ^. AWS.serviceError_status == status400 && "Invalid domain name" - `Text.isPrefixOf` AWS.toText (se ^. AWS.serviceCode) -> + `Text.isPrefixOf` AWS.toText (se ^. AWS.serviceError_code) -> throwM SESInvalidDomain _ -> throwM (GeneralError x) @@ -268,7 +269,7 @@ canRetry :: MonadIO m => Either AWS.Error a -> m Bool canRetry (Right _) = pure False canRetry (Left e) = case e of AWS.TransportError (HttpExceptionRequest _ ResponseTimeout) -> pure True - AWS.ServiceError se | se ^. AWS.serviceCode == AWS.ErrorCode "RequestThrottled" -> pure True + AWS.ServiceError se | se ^. AWS.serviceError_code == AWS.ErrorCode "RequestThrottled" -> pure True _ -> pure False retry5x :: (Monad m) => RetryPolicyM m diff --git a/services/brig/src/Brig/Data/Client.hs b/services/brig/src/Brig/Data/Client.hs index 7d4587a164..5e532d974e 100644 --- a/services/brig/src/Brig/Data/Client.hs +++ b/services/brig/src/Brig/Data/Client.hs @@ -49,6 +49,7 @@ module Brig.Data.Client where import qualified Amazonka as AWS +import qualified Amazonka.Data.Text as AWS import qualified Amazonka.DynamoDB as AWS import qualified Amazonka.DynamoDB.Lens as AWS import Bilge.Retry (httpHandlers) @@ -567,7 +568,7 @@ withOptLock u c ma = go (10 :: Int) run = execCatch e cmd >>= either handleErr (pure . conv) handlers = httpHandlers ++ [const $ EL.handler_ AWS._ConditionalCheckFailedException (pure True)] policy = limitRetries 3 <> exponentialBackoff 100000 - handleErr (AWS.ServiceError se) | se ^. AWS.serviceCode == AWS.ErrorCode "ProvisionedThroughputExceeded" = do + handleErr (AWS.ServiceError se) | se ^. AWS.serviceError_code == AWS.ErrorCode "ProvisionedThroughputExceeded" = do Metrics.counterIncr (Metrics.path "client.opt_lock.provisioned_throughput_exceeded") m pure Nothing handleErr _ = pure Nothing diff --git a/services/cargohold/src/CargoHold/AWS.hs b/services/cargohold/src/CargoHold/AWS.hs index f7d0fd2df4..e00063457a 100644 --- a/services/cargohold/src/CargoHold/AWS.hs +++ b/services/cargohold/src/CargoHold/AWS.hs @@ -70,9 +70,10 @@ data Env = Env makeLenses ''Env -- | Override the endpoint in the '_amazonkaEnv' with '_amazonkaDownloadEndpoint'. +-- TODO: Choose the correct s3 addressing style amazonkaEnvWithDownloadEndpoint :: Env -> AWS.Env amazonkaEnvWithDownloadEndpoint e = - AWS.override (setAWSEndpoint (e ^. amazonkaDownloadEndpoint)) (e ^. amazonkaEnv) + AWS.overrideService (setAWSEndpoint (e ^. amazonkaDownloadEndpoint)) (e ^. amazonkaEnv) setAWSEndpoint :: AWSEndpoint -> AWS.Service -> AWS.Service setAWSEndpoint e = AWS.setEndpoint (_awsSecure e) (_awsHost e) (_awsPort e) @@ -100,6 +101,7 @@ mkEnv :: Logger -> -- | S3 endpoint AWSEndpoint -> + AWS.S3AddressingStyle -> -- | Endpoint for downloading assets (for the external world) AWSEndpoint -> -- | Bucket @@ -107,9 +109,9 @@ mkEnv :: Maybe CloudFrontOpts -> Manager -> IO Env -mkEnv lgr s3End s3Download bucket cfOpts mgr = do +mkEnv lgr s3End s3AddrStyle s3Download bucket cfOpts mgr = do let g = Logger.clone (Just "aws.cargohold") lgr - e <- mkAwsEnv g (setAWSEndpoint s3End S3.defaultService) + e <- mkAwsEnv g (setAWSEndpoint s3End (S3.defaultService & AWS.service_s3AddressingStyle .~ s3AddrStyle)) cf <- mkCfEnv cfOpts pure (Env g bucket e s3Download cf) where @@ -118,11 +120,11 @@ mkEnv lgr s3End s3Download bucket cfOpts mgr = do mkAwsEnv g s3 = do baseEnv <- AWS.newEnv AWS.discover - <&> AWS.configure s3 + <&> AWS.configureService s3 pure $ baseEnv - { AWS.envLogger = awsLogger g, - AWS.envManager = mgr + { AWS.logger = awsLogger g, + AWS.manager = mgr } awsLogger g l = Logger.log g (mapLevel l) . Log.msg . toLazyByteString mapLevel AWS.Info = Logger.Info @@ -222,7 +224,7 @@ canRetry :: MonadIO m => Either AWS.Error a -> m Bool canRetry (Right _) = pure False canRetry (Left e) = case e of AWS.TransportError (HttpExceptionRequest _ ResponseTimeout) -> pure True - AWS.ServiceError se | se ^. AWS.serviceCode == AWS.ErrorCode "RequestThrottled" -> pure True + AWS.ServiceError se | se ^. AWS.serviceError_code == AWS.ErrorCode "RequestThrottled" -> pure True _ -> pure False retry5x :: (Monad m) => RetryPolicyM m diff --git a/services/cargohold/src/CargoHold/App.hs b/services/cargohold/src/CargoHold/App.hs index 83226c45cf..b123ed739f 100644 --- a/services/cargohold/src/CargoHold/App.hs +++ b/services/cargohold/src/CargoHold/App.hs @@ -46,6 +46,7 @@ module CargoHold.App ) where +import Amazonka (S3AddressingStyle (S3AddressingStylePath)) import Bilge (Manager, MonadHttp, RequestId (..), newManager, withResponse) import qualified Bilge import Bilge.RPC (HasRequestId (..)) @@ -97,9 +98,10 @@ newEnv o = do pure $ Env ama met lgr mgr def o loc initAws :: AWSOpts -> Logger -> Manager -> IO AWS.Env -initAws o l = AWS.mkEnv l (o ^. awsS3Endpoint) downloadEndpoint (o ^. awsS3Bucket) (o ^. awsCloudFront) +initAws o l = AWS.mkEnv l (o ^. awsS3Endpoint) addrStyle downloadEndpoint (o ^. awsS3Bucket) (o ^. awsCloudFront) where downloadEndpoint = fromMaybe (o ^. awsS3Endpoint) (o ^. awsS3DownloadEndpoint) + addrStyle = maybe S3AddressingStylePath unwrapS3AddressingStyle (o ^. awsS3AddressingStyle) initHttpManager :: Maybe S3Compatibility -> IO Manager initHttpManager s3Compat = diff --git a/services/cargohold/src/CargoHold/Options.hs b/services/cargohold/src/CargoHold/Options.hs index 3f709c1454..7a96fdef70 100644 --- a/services/cargohold/src/CargoHold/Options.hs +++ b/services/cargohold/src/CargoHold/Options.hs @@ -20,6 +20,7 @@ module CargoHold.Options where +import Amazonka (S3AddressingStyle (..)) import qualified CargoHold.CloudFront as CF import Control.Lens hiding (Level) import Data.Aeson (FromJSON (..), withText) @@ -45,8 +46,48 @@ deriveFromJSON toOptionFieldName ''CloudFrontOpts makeLenses ''CloudFrontOpts +newtype OptS3AddressingStyle = OptS3AddressingStyle + { unwrapS3AddressingStyle :: S3AddressingStyle + } + deriving (Show) + +instance FromJSON OptS3AddressingStyle where + parseJSON = + withText "S3AddressingStyle" $ + fmap OptS3AddressingStyle . \case + "auto" -> pure S3AddressingStyleAuto + "path" -> pure S3AddressingStylePath + "virtual" -> pure S3AddressingStyleVirtual + other -> fail $ "invalid S3AddressingStyle: " <> show other + data AWSOpts = AWSOpts { _awsS3Endpoint :: !AWSEndpoint, + -- | S3 can either by addressed in path style, i.e. + -- https:////, or vhost style, i.e. + -- https://./. AWS's S3 offering has + -- deprecated path style addressing for S3 and completely disabled it for + -- buckets created after 30 Sep 2020: + -- https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ + -- + -- However other object storage providers (specially self-deployed ones like + -- MinIO) may not support vhost style addressing yet (or ever?). Users of + -- such buckets should configure this option to "path". + -- + -- Installations using S3 service provided by AWS, should use "auto", this + -- option will ensure that vhost style is only used when it is possible to + -- construct a valid hostname from the bucket name and the bucket name + -- doesn't contain a '.'. Having a '.' in the bucket name causes TLS + -- validation to fail, hence it is not used by default. + -- + -- Using "virtual" as an option is only useful in situations where vhost + -- style addressing must be used even if it is not possible to construct a + -- valid hostname from the bucket name or the S3 service provider can ensure + -- correct certificate is issued for bucket which contain one or more '.'s + -- in the name. + -- + -- When this option is unspecified, we default to path style addressing to + -- ensure smooth transition for older deployments. + _awsS3AddressingStyle :: !(Maybe OptS3AddressingStyle), -- | S3 endpoint for generating download links. Useful if Cargohold is configured to use -- an S3 replacement running inside the internal network (in which case internally we -- would use one hostname for S3, and when generating an asset link for a client app, we diff --git a/services/cargohold/src/CargoHold/S3.hs b/services/cargohold/src/CargoHold/S3.hs index 404abb79e8..29137efe3a 100644 --- a/services/cargohold/src/CargoHold/S3.hs +++ b/services/cargohold/src/CargoHold/S3.hs @@ -36,7 +36,7 @@ module CargoHold.S3 ) where -import Amazonka hiding (Error, ToByteString, (.=)) +import Amazonka hiding (Error) import Amazonka.S3 import Amazonka.S3.Lens import CargoHold.API.Error @@ -145,7 +145,7 @@ downloadV3 :: ExceptT Error App (ConduitM () ByteString (ResourceT IO) ()) downloadV3 (s3Key . mkKey -> key) = do env <- view aws - pure . flattenResourceT $ _streamBody . view getObjectResponse_body <$> AWS.execStream env req + pure . flattenResourceT $ view (getObjectResponse_body . _ResponseBody) <$> AWS.execStream env req where req :: Text -> GetObject req b = diff --git a/services/galley/src/Galley/Aws.hs b/services/galley/src/Galley/Aws.hs index 1ef921a705..c1ee608b6f 100644 --- a/services/galley/src/Galley/Aws.hs +++ b/services/galley/src/Galley/Aws.hs @@ -109,12 +109,12 @@ mkEnv lgr mgr opts = do mkAwsEnv g = do baseEnv <- AWS.newEnv AWS.discover - <&> AWS.configure (sqs (opts ^. awsEndpoint)) + <&> AWS.configureService (sqs (opts ^. awsEndpoint)) pure $ baseEnv - { AWS.envLogger = awsLogger g, - AWS.envRetryCheck = retryCheck, - AWS.envManager = mgr + { AWS.logger = awsLogger g, + AWS.retryCheck = retryCheck, + AWS.manager = mgr } awsLogger g l = Logger.log g (mapLevel l) . Logger.msg . toLazyByteString mapLevel AWS.Info = Logger.Info @@ -183,5 +183,5 @@ canRetry :: MonadIO m => Either AWS.Error a -> m Bool canRetry (Right _) = pure False canRetry (Left e) = case e of AWS.TransportError (HttpExceptionRequest _ ResponseTimeout) -> pure True - AWS.ServiceError se | se ^. AWS.serviceCode == AWS.ErrorCode "RequestThrottled" -> pure True + AWS.ServiceError se | se ^. AWS.serviceError_code == AWS.ErrorCode "RequestThrottled" -> pure True _ -> pure False diff --git a/services/gundeck/default.nix b/services/gundeck/default.nix index 3b4c5dc779..917012e8fe 100644 --- a/services/gundeck/default.nix +++ b/services/gundeck/default.nix @@ -6,6 +6,7 @@ , aeson , aeson-pretty , amazonka +, amazonka-core , amazonka-sns , amazonka-sqs , async @@ -99,6 +100,7 @@ mkDerivation { libraryHaskellDepends = [ aeson amazonka + amazonka-core amazonka-sns amazonka-sqs async diff --git a/services/gundeck/gundeck.cabal b/services/gundeck/gundeck.cabal index 82f74e893e..1a079e716f 100644 --- a/services/gundeck/gundeck.cabal +++ b/services/gundeck/gundeck.cabal @@ -98,9 +98,10 @@ library build-depends: aeson >=2.0.1.0 - , amazonka >=1.3.7 - , amazonka-sns >=1.3.7 - , amazonka-sqs >=1.3.7 + , amazonka >=2 + , amazonka-core >=2 + , amazonka-sns >=2 + , amazonka-sqs >=2 , async >=2.0 , attoparsec >=0.10 , auto-update >=0.1 diff --git a/services/gundeck/src/Gundeck/Aws.hs b/services/gundeck/src/Gundeck/Aws.hs index 54c944dc88..ab9f7e839d 100644 --- a/services/gundeck/src/Gundeck/Aws.hs +++ b/services/gundeck/src/Gundeck/Aws.hs @@ -54,8 +54,9 @@ module Gundeck.Aws ) where -import Amazonka (AWSRequest, AWSResponse, serviceAbbrev, serviceCode, serviceMessage, serviceStatus) +import Amazonka (AWSRequest, AWSResponse, serviceError_abbrev, serviceError_code, serviceError_message, serviceError_status) import qualified Amazonka as AWS +import qualified Amazonka.Data.Text as AWS import qualified Amazonka.SNS as SNS import qualified Amazonka.SNS.Lens as SNS import qualified Amazonka.SQS as SQS @@ -160,14 +161,14 @@ mkEnv lgr opts mgr = do mkAwsEnv g sqs sns = do baseEnv <- AWS.newEnv AWS.discover - <&> AWS.configure sqs - <&> AWS.configure (sns & set AWS.serviceTimeout (Just (AWS.Seconds 5))) + <&> AWS.configureService sqs + <&> AWS.configureService (sns & set AWS.service_timeout (Just (AWS.Seconds 5))) pure $ baseEnv - { AWS.envLogger = awsLogger g, - AWS.envRegion = opts ^. optAws . awsRegion, - AWS.envRetryCheck = retryCheck, - AWS.envManager = mgr + { AWS.logger = awsLogger g, + AWS.region = opts ^. optAws . awsRegion, + AWS.retryCheck = retryCheck, + AWS.manager = mgr } awsLogger g l = Logger.log g (mapLevel l) . Logger.msg . toLazyByteString @@ -240,8 +241,8 @@ updateEndpoint us tk arn = do Right _ -> pure () Left x@(AWS.ServiceError e) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isMetadataLengthError (e ^. serviceMessage) -> + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isMetadataLengthError (e ^. serviceError_message) -> throwM $ InvalidCustomData arn Left x -> throwM $ @@ -303,16 +304,16 @@ createEndpoint u tr arnEnv app token = do Nothing -> throwM NoEndpointArn Just s -> Right <$> readArn s Left x@(AWS.ServiceError e) - | is "SNS" 400 x && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode, - Just ep <- parseExistsError (e ^. serviceMessage) -> + | is "SNS" 400 x && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code, + Just ep <- parseExistsError (e ^. serviceError_message) -> pure (Left (EndpointInUse ep)) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isLengthError (e ^. serviceMessage) -> + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isLengthError (e ^. serviceError_message) -> pure (Left (TokenTooLong $ tokenLength token)) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isTokenError (e ^. serviceMessage) -> do + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isTokenError (e ^. serviceError_message) -> do debug $ msg @Text "InvalidParameter: InvalidToken" . field "response" (show x) @@ -409,19 +410,19 @@ publish arn txt attrs = do case res of Right _ -> pure (Right ()) Left x@(AWS.ServiceError e) - | is "SNS" 400 x && AWS.newErrorCode "EndpointDisabled" == e ^. serviceCode -> + | is "SNS" 400 x && AWS.newErrorCode "EndpointDisabled" == e ^. serviceError_code -> pure (Left (EndpointDisabled arn)) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isProtocolSizeError (e ^. serviceMessage) -> + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isProtocolSizeError (e ^. serviceError_message) -> pure (Left (PayloadTooLarge arn)) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isSnsSizeError (e ^. serviceMessage) -> + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isSnsSizeError (e ^. serviceError_message) -> pure (Left (PayloadTooLarge arn)) | is "SNS" 400 x - && AWS.newErrorCode "InvalidParameter" == e ^. serviceCode - && isArnError (e ^. serviceMessage) -> + && AWS.newErrorCode "InvalidParameter" == e ^. serviceError_code + && isArnError (e ^. serviceError_message) -> pure (Left (InvalidEndpoint arn)) Left x -> throwM (GeneralError x) where @@ -488,7 +489,7 @@ send :: AWSRequest r => AWS.Env -> r -> Amazon (AWSResponse r) send env r = either (throwM . GeneralError) pure =<< sendCatch env r is :: AWS.Abbrev -> Int -> AWS.Error -> Bool -is srv s (AWS.ServiceError e) = srv == e ^. serviceAbbrev && s == statusCode (e ^. serviceStatus) +is srv s (AWS.ServiceError e) = srv == e ^. serviceError_abbrev && s == statusCode (e ^. serviceError_status) is _ _ _ = False isTimeout :: MonadIO m => Either AWS.Error a -> m Bool From 80cdd51ddca2fff305165ebf35c762fab066c387 Mon Sep 17 00:00:00 2001 From: Amit Sagtani Date: Mon, 2 Jan 2023 23:58:05 +0530 Subject: [PATCH 06/33] add default host address in values.yaml for inbucket chart (#2958) --- charts/inbucket/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/inbucket/values.yaml b/charts/inbucket/values.yaml index 626051a534..3bff990c7e 100644 --- a/charts/inbucket/values.yaml +++ b/charts/inbucket/values.yaml @@ -1,6 +1,6 @@ # Fully qualified domain name (FQDN) of the domain where to serve inbucket. # E.g. 'inbucket.my-test-env.wire.link' -host: +host: "inbucket.example.com" # Configure the inbucket "parent" chart inbucket: From d377ffd87066ed286e9cc19ec223190a1c8fd32b Mon Sep 17 00:00:00 2001 From: Sandy Maguire Date: Wed, 4 Jan 2023 02:19:18 -0800 Subject: [PATCH 07/33] MakesFederatedCall servant combinator (#2950) * feat: track federation api calls * chore: make format * fix: give a default instance for other packages * feat: galley callsfed tracking * chore: make format * fix: cargohold * chore: make format * doc: changelog.d * feat: MakesFederatedCall servant combinator * chore: make format * doc: haddock * fix: add RoutesToPaths instance * feat: use updated extension point for MakesFederatedCall * chore: make format * chore: remove spurious HasCallStack * feat: add some federated calls to brig * feat: federated calls in brig/client API * feat: more api calls * fix: add callsFed * feat: finish adding MakesFederatedCall documentation * chore: make format * feat: cargohold api * Add changelogs * Fix compilation of integration tests in Brig * Revert "Fix compilation of integration tests in Brig" This reverts commit 2310a32d780cdc624295a74a6af5be2f47733b55. * fix: clean up brig integration test callsites * feat: SolveCallsFed for variadic numbers of callsFed --- changelog.d/5-internal/makes-federated-call | 1 + changelog.d/6-federation/swagger-extension | 1 + .../src/Wire/API/Federation/API.hs | 21 ++- .../src/Wire/API/Federation/Component.hs | 16 +- .../src/Wire/API/MakesFederatedCall.hs | 143 ++++++++++++++++++ .../src/Wire/API/Routes/Internal/Brig.hs | 5 + .../src/Wire/API/Routes/Public/Brig.hs | 46 +++++- .../src/Wire/API/Routes/Public/Cargohold.hs | 5 + libs/wire-api/wire-api.cabal | 1 + nix/haskell-pins.nix | 7 + services/brig/src/Brig/API.hs | 4 +- services/brig/src/Brig/API/Internal.hs | 21 ++- services/brig/src/Brig/API/Public.hs | 86 +++++------ services/brig/src/Brig/Run.hs | 8 +- services/brig/test/integration/Util.hs | 4 +- .../cargohold/src/CargoHold/API/Public.hs | 8 +- services/cargohold/src/CargoHold/Run.hs | 4 - .../test/integration/API/Federation.hs | 14 +- 18 files changed, 295 insertions(+), 100 deletions(-) create mode 100644 changelog.d/5-internal/makes-federated-call create mode 100644 changelog.d/6-federation/swagger-extension create mode 100644 libs/wire-api/src/Wire/API/MakesFederatedCall.hs diff --git a/changelog.d/5-internal/makes-federated-call b/changelog.d/5-internal/makes-federated-call new file mode 100644 index 0000000000..556a1009b0 --- /dev/null +++ b/changelog.d/5-internal/makes-federated-call @@ -0,0 +1 @@ +Introduce the `MakesFederatedCall` Servant combinator diff --git a/changelog.d/6-federation/swagger-extension b/changelog.d/6-federation/swagger-extension new file mode 100644 index 0000000000..8b7c65135a --- /dev/null +++ b/changelog.d/6-federation/swagger-extension @@ -0,0 +1 @@ +Injects federated calls into the `x-wire-makes-federated-calls-to` extension of the swagger Operations diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API.hs b/libs/wire-api-federation/src/Wire/API/Federation/API.hs index 703fdc8dfa..7fc6e981b0 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API.hs @@ -18,9 +18,11 @@ module Wire.API.Federation.API ( FedApi, HasFedEndpoint, + HasUnsafeFedEndpoint, fedClient, fedClientIn, - CallsFed, + unsafeFedClientIn, + module Wire.API.MakesFederatedCall, -- * Re-exports Component (..), @@ -36,7 +38,7 @@ import Wire.API.Federation.API.Brig import Wire.API.Federation.API.Cargohold import Wire.API.Federation.API.Galley import Wire.API.Federation.Client -import Wire.API.Federation.Component +import Wire.API.MakesFederatedCall import Wire.API.Routes.Named -- Note: this type family being injective means that in most cases there is no need @@ -49,9 +51,12 @@ type instance FedApi 'Brig = BrigApi type instance FedApi 'Cargohold = CargoholdApi -type HasFedEndpoint comp api name = ('Just api ~ LookupEndpoint (FedApi comp) name, CallsFed comp name) +type HasFedEndpoint comp api name = (HasUnsafeFedEndpoint comp api name, CallsFed comp name) -class CallsFed (comp :: Component) (name :: Symbol) +-- | Like 'HasFedEndpoint', but doesn't propagate a 'CallsFed' constraint. +-- Useful for tests, but unsafe in the sense that incorrect usage will allow +-- you to forget about some federated calls. +type HasUnsafeFedEndpoint comp api name = 'Just api ~ LookupEndpoint (FedApi comp) name -- | Return a client for a named endpoint. fedClient :: @@ -65,3 +70,11 @@ fedClientIn :: (HasFedEndpoint comp api name, HasClient m api) => Client m api fedClientIn = clientIn (Proxy @api) (Proxy @m) + +-- | Like 'fedClientIn', but doesn't propagate a 'CallsFed' constraint. Inteded +-- to be used in test situations only. +unsafeFedClientIn :: + forall (comp :: Component) (name :: Symbol) m api. + (HasUnsafeFedEndpoint comp api name, HasClient m api) => + Client m api +unsafeFedClientIn = clientIn (Proxy @api) (Proxy @m) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Component.hs b/libs/wire-api-federation/src/Wire/API/Federation/Component.hs index 908f3b01c4..73595904f7 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Component.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Component.hs @@ -15,18 +15,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.Federation.Component where +module Wire.API.Federation.Component + ( module Wire.API.Federation.Component, + Component (..), + ) +where import Imports -import Test.QuickCheck (Arbitrary) -import Wire.Arbitrary (GenericUniform (..)) - -data Component - = Brig - | Galley - | Cargohold - deriving (Show, Eq, Generic) - deriving (Arbitrary) via (GenericUniform Component) +import Wire.API.MakesFederatedCall (Component (..)) parseComponent :: Text -> Maybe Component parseComponent "brig" = Just Brig diff --git a/libs/wire-api/src/Wire/API/MakesFederatedCall.hs b/libs/wire-api/src/Wire/API/MakesFederatedCall.hs new file mode 100644 index 0000000000..a6abb32dc0 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MakesFederatedCall.hs @@ -0,0 +1,143 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE OverloadedLists #-} + +module Wire.API.MakesFederatedCall + ( CallsFed, + MakesFederatedCall, + Component (..), + callsFed, + unsafeCallsFed, + ) +where + +import Data.Aeson (Value (..)) +import Data.Constraint +import Data.Metrics.Servant +import Data.Proxy +import Data.Swagger.Operation (addExtensions) +import qualified Data.Text as T +import GHC.TypeLits +import Imports +import Servant.API +import Servant.Client +import Servant.Server +import Servant.Swagger +import Test.QuickCheck (Arbitrary) +import Unsafe.Coerce (unsafeCoerce) +import Wire.Arbitrary (GenericUniform (..)) + +data Component + = Brig + | Galley + | Cargohold + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform Component) + +-- | A typeclass corresponding to calls to federated services. This class has +-- no methods, and exists only to automatically propagate information up to +-- servant. +-- +-- The only way to discharge this constraint is via 'callsFed', which should be +-- invoked for each federated call when connecting handlers to the server +-- definition. +class CallsFed (comp :: Component) (name :: Symbol) + +-- | A typeclass with the same layout as 'CallsFed', which exists only so we +-- can discharge 'CallsFeds' constraints by unsafely coercing this one. +class Nullary + +instance Nullary + +-- | Construct a dictionary for 'CallsFed'. +synthesizeCallsFed :: forall (comp :: Component) (name :: Symbol). Dict (CallsFed comp name) +synthesizeCallsFed = unsafeCoerce $ Dict @Nullary + +-- | Servant combinator for tracking calls to federated calls. Annotating API +-- endpoints with 'MakesFederatedCall' is the only way to eliminate 'CallsFed' +-- constraints on handlers. +data MakesFederatedCall (comp :: Component) (name :: Symbol) + +instance (HasServer api ctx) => HasServer (MakesFederatedCall comp name :> api :: *) ctx where + -- \| This should have type @CallsFed comp name => ServerT api m@, but GHC + -- complains loudly thinking this is a polytype. We need to introduce the + -- 'CallsFed' constraint so that we can eliminate it via + -- 'synthesizeCallsFed', which otherwise is too-high rank for GHC to notice + -- we've solved our constraint. + type ServerT (MakesFederatedCall comp name :> api) m = Dict (CallsFed comp name) -> ServerT api m + route _ ctx f = route (Proxy @api) ctx $ fmap ($ synthesizeCallsFed @comp @name) f + hoistServerWithContext _ ctx f s = hoistServerWithContext (Proxy @api) ctx f . s + +instance HasLink api => HasLink (MakesFederatedCall comp name :> api :: *) where + type MkLink (MakesFederatedCall comp name :> api) x = MkLink api x + toLink f _ l = toLink f (Proxy @api) l + +instance RoutesToPaths api => RoutesToPaths (MakesFederatedCall comp name :> api :: *) where + getRoutes = getRoutes @api + +-- | Get a symbol representation of our component. +type family ShowComponent (x :: Component) :: Symbol where + ShowComponent 'Brig = "brig" + ShowComponent 'Galley = "galley" + ShowComponent 'Cargohold = "cargohold" + +-- | 'MakesFederatedCall' annotates the swagger documentation with an extension +-- tag @x-wire-makes-federated-calls-to@. +instance (HasSwagger api, KnownSymbol name, KnownSymbol (ShowComponent comp)) => HasSwagger (MakesFederatedCall comp name :> api :: *) where + toSwagger _ = + toSwagger (Proxy @api) + & addExtensions + mergeJSONArray + [ ( "wire-makes-federated-call-to", + Array + [ Array + [ String $ T.pack $ symbolVal $ Proxy @(ShowComponent comp), + String $ T.pack $ symbolVal $ Proxy @name + ] + ] + ) + ] + +mergeJSONArray :: Value -> Value -> Value +mergeJSONArray (Array x) (Array y) = Array $ x <> y +mergeJSONArray _ _ = error "impossible! bug in construction of federated calls JSON" + +instance HasClient m api => HasClient m (MakesFederatedCall comp name :> api :: *) where + type Client m (MakesFederatedCall comp name :> api) = Client m api + clientWithRoute p _ = clientWithRoute p $ Proxy @api + hoistClientMonad p _ f c = hoistClientMonad p (Proxy @api) f c + +-- | Type class to automatically lift a function of the form @(c1, c2, ...) => +-- r@ into @Dict c1 -> Dict c2 -> ... -> r@. +class SolveCallsFed c r a where + -- | Safely discharge a 'CallsFed' constraint. Intended to be used when + -- connecting your handler to the server router. + callsFed :: (c => r) -> a + +instance (c ~ ((k, d) :: Constraint), SolveCallsFed d r a) => SolveCallsFed c r (Dict k -> a) where + callsFed f Dict = callsFed @d @r @a f + +instance {-# OVERLAPPABLE #-} (c ~ (() :: Constraint), r ~ a) => SolveCallsFed c r a where + callsFed f = f + +-- | Unsafely discharge a 'CallsFed' constraint. Necessary for interacting with +-- wai-routes. +-- +-- This is unsafe in the sense that it will drop the 'CallsFed' constraint, and +-- thus might mean a federated call gets forgotten in the documentation. +unsafeCallsFed :: forall (comp :: Component) (name :: Symbol) r. (CallsFed comp name => r) -> r +unsafeCallsFed f = withDict (synthesizeCallsFed @comp @name) f diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 3192f9ca00..c42b16e029 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -54,6 +54,7 @@ import Wire.API.Error import Wire.API.Error.Brig import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage +import Wire.API.MakesFederatedCall import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Routes.Internal.Brig.EJPD import qualified Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti as Multi @@ -151,6 +152,7 @@ type AccountAPI = Named "createUserNoVerify" ( "users" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ReqBody '[Servant.JSON] NewUser :> MultiVerb 'POST '[Servant.JSON] RegisterInternalResponses (Either RegisterError SelfProfile) ) @@ -158,6 +160,7 @@ type AccountAPI = "createUserNoVerifySpar" ( "users" :> "spar" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ReqBody '[Servant.JSON] NewUserSpar :> MultiVerb 'POST '[Servant.JSON] CreateUserSparInternalResponses (Either CreateUserSparError SelfProfile) ) @@ -366,12 +369,14 @@ type AuthAPI = Named "legalhold-login" ( "legalhold-login" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ReqBody '[JSON] LegalHoldLogin :> MultiVerb1 'POST '[JSON] TokenResponse ) :<|> Named "sso-login" ( "sso-login" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ReqBody '[JSON] SsoLogin :> QueryParam' [Optional, Strict] "persist" Bool :> MultiVerb1 'POST '[JSON] TokenResponse diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index d1f3eae1e6..430b20c002 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -48,6 +48,7 @@ import Wire.API.Error.Brig import Wire.API.Error.Empty import Wire.API.MLS.KeyPackage import Wire.API.MLS.Servant +import Wire.API.MakesFederatedCall import Wire.API.Properties import Wire.API.Routes.Bearer import Wire.API.Routes.Cookies @@ -140,6 +141,7 @@ type UserAPI = Named "get-user-unqualified" ( Summary "Get a user by UserId" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> Until 'V2 :> ZUser :> "users" @@ -151,6 +153,7 @@ type UserAPI = Named "get-user-qualified" ( Summary "Get a user by Domain and UserId" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZUser :> "users" :> QualifiedCaptureUserId "uid" @@ -171,6 +174,8 @@ type UserAPI = "get-handle-info-unqualified" ( Summary "(deprecated, use /search/contacts) Get information on a user handle" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-by-handle" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZUser :> "users" :> "handles" @@ -187,6 +192,8 @@ type UserAPI = "get-user-by-handle-qualified" ( Summary "(deprecated, use /search/contacts) Get information on a user handle" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-by-handle" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZUser :> "users" :> "by-handle" @@ -206,6 +213,7 @@ type UserAPI = ( Summary "List users (deprecated)" :> Until 'V2 :> Description "The 'ids' and 'handles' parameters are mutually exclusive." + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZUser :> "users" :> QueryParam' [Optional, Strict, Description "User IDs of users to fetch"] "ids" (CommaSeparatedList UserId) @@ -218,6 +226,7 @@ type UserAPI = "list-users-by-ids-or-handles" ( Summary "List users" :> Description "The 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive." + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZUser :> "list-users" :> ReqBody '[JSON] ListUsersQuery @@ -274,6 +283,7 @@ type SelfAPI = :> CanThrow 'MissingAuth :> CanThrow 'DeleteCodePending :> CanThrow 'OwnerDeletingSelf + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> "self" :> ReqBody '[JSON] DeleteUser @@ -285,6 +295,7 @@ type SelfAPI = Named "put-self" ( Summary "Update your profile." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> ZConn :> "self" @@ -310,6 +321,7 @@ type SelfAPI = :> Description "Your phone number can only be removed if you also have an \ \email address and a password." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> ZConn :> "self" @@ -325,6 +337,7 @@ type SelfAPI = :> Description "Your email address can only be removed if you also have a \ \phone number." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> ZConn :> "self" @@ -357,6 +370,7 @@ type SelfAPI = :<|> Named "change-locale" ( Summary "Change your locale." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> ZConn :> "self" @@ -367,6 +381,7 @@ type SelfAPI = :<|> Named "change-handle" ( Summary "Change your handle." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ZUser :> ZConn :> "self" @@ -418,6 +433,7 @@ type AccountAPI = "If the environment where the registration takes \ \place is private and a registered email address or phone \ \number is not whitelisted, a 403 error is returned." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> "register" :> ReqBody '[JSON] NewUserPublic :> MultiVerb 'POST '[JSON] RegisterResponses (Either RegisterError RegisterSuccess) @@ -428,6 +444,7 @@ type AccountAPI = :<|> Named "verify-delete" ( Summary "Verify account deletion with a code." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> CanThrow 'InvalidCode :> "delete" :> ReqBody '[JSON] VerifyDeleteUser @@ -440,6 +457,7 @@ type AccountAPI = "get-activate" ( Summary "Activate (i.e. confirm) an email address or phone number." :> Description "See also 'POST /activate' which has a larger feature set." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> CanThrow 'UserKeyExists :> CanThrow 'InvalidActivationCodeWrongUser :> CanThrow 'InvalidActivationCodeWrongCode @@ -465,6 +483,7 @@ type AccountAPI = :> Description "Activation only succeeds once and the number of \ \failed attempts for a valid key is limited." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> CanThrow 'UserKeyExists :> CanThrow 'InvalidActivationCodeWrongUser :> CanThrow 'InvalidActivationCodeWrongCode @@ -578,6 +597,7 @@ type PrekeyAPI = "get-users-prekeys-client-unqualified" ( Summary "(deprecated) Get a prekey for a specific client of a user." :> Until 'V2 + :> MakesFederatedCall 'Brig "claim-prekey" :> ZUser :> "users" :> CaptureUserId "uid" @@ -588,6 +608,7 @@ type PrekeyAPI = :<|> Named "get-users-prekeys-client-qualified" ( Summary "Get a prekey for a specific client of a user." + :> MakesFederatedCall 'Brig "claim-prekey" :> ZUser :> "users" :> QualifiedCaptureUserId "uid" @@ -599,6 +620,7 @@ type PrekeyAPI = "get-users-prekey-bundle-unqualified" ( Summary "(deprecated) Get a prekey for each client of a user." :> Until 'V2 + :> MakesFederatedCall 'Brig "claim-prekey-bundle" :> ZUser :> "users" :> CaptureUserId "uid" @@ -608,6 +630,7 @@ type PrekeyAPI = :<|> Named "get-users-prekey-bundle-qualified" ( Summary "Get a prekey for each client of a user." + :> MakesFederatedCall 'Brig "claim-prekey-bundle" :> ZUser :> "users" :> QualifiedCaptureUserId "uid" @@ -633,6 +656,7 @@ type PrekeyAPI = "Given a map of domain to (map of user IDs to client IDs) return a \ \prekey for each one. You can't request information for more users than \ \maximum conversation size." + :> MakesFederatedCall 'Brig "claim-multi-prekey-bundle" :> ZUser :> "users" :> "list-prekeys" @@ -649,6 +673,7 @@ type UserClientAPI = Named "add-client" ( Summary "Register a new client" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> CanThrow 'TooManyClients :> CanThrow 'MissingAuth :> CanThrow 'MalformedPrekeys @@ -784,6 +809,7 @@ type ClientAPI = "get-user-clients-unqualified" ( Summary "Get all of a user's clients" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-clients" :> "users" :> CaptureUserId "uid" :> "clients" @@ -792,6 +818,7 @@ type ClientAPI = :<|> Named "get-user-clients-qualified" ( Summary "Get all of a user's clients" + :> MakesFederatedCall 'Brig "get-user-clients" :> "users" :> QualifiedCaptureUserId "uid" :> "clients" @@ -801,6 +828,7 @@ type ClientAPI = "get-user-client-unqualified" ( Summary "Get a specific client of a user" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-clients" :> "users" :> CaptureUserId "uid" :> "clients" @@ -810,6 +838,7 @@ type ClientAPI = :<|> Named "get-user-client-qualified" ( Summary "Get a specific client of a user" + :> MakesFederatedCall 'Brig "get-user-clients" :> "users" :> QualifiedCaptureUserId "uid" :> "clients" @@ -820,6 +849,7 @@ type ClientAPI = "list-clients-bulk" ( Summary "List all clients for a set of user ids" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-clients" :> ZUser :> "users" :> "list-clients" @@ -830,6 +860,7 @@ type ClientAPI = "list-clients-bulk-v2" ( Summary "List all clients for a set of user ids" :> Until 'V2 + :> MakesFederatedCall 'Brig "get-user-clients" :> ZUser :> "users" :> "list-clients" @@ -841,6 +872,7 @@ type ClientAPI = "list-clients-bulk@v2" ( Summary "List all clients for a set of user ids" :> From 'V2 + :> MakesFederatedCall 'Brig "get-user-clients" :> ZUser :> "users" :> "list-clients" @@ -861,6 +893,7 @@ type ConnectionAPI = "create-connection-unqualified" ( Summary "Create a connection to another user" :> Until 'V2 + :> MakesFederatedCall 'Brig "send-connection-action" :> CanThrow 'MissingLegalholdConsent :> CanThrow 'InvalidUser :> CanThrow 'ConnectionLimitReached @@ -883,6 +916,7 @@ type ConnectionAPI = :<|> Named "create-connection" ( Summary "Create a connection to another user" + :> MakesFederatedCall 'Brig "send-connection-action" :> CanThrow 'MissingLegalholdConsent :> CanThrow 'InvalidUser :> CanThrow 'ConnectionLimitReached @@ -961,6 +995,7 @@ type ConnectionAPI = "update-connection-unqualified" ( Summary "Update a connection to another user" :> Until 'V2 + :> MakesFederatedCall 'Brig "send-connection-action" :> CanThrow 'MissingLegalholdConsent :> CanThrow 'InvalidUser :> CanThrow 'ConnectionLimitReached @@ -988,6 +1023,7 @@ type ConnectionAPI = Named "update-connection" ( Summary "Update a connection to another user" + :> MakesFederatedCall 'Brig "send-connection-action" :> CanThrow 'MissingLegalholdConsent :> CanThrow 'InvalidUser :> CanThrow 'ConnectionLimitReached @@ -1008,6 +1044,8 @@ type ConnectionAPI = :<|> Named "search-contacts" ( Summary "Search for users" + :> MakesFederatedCall 'Brig "get-users-by-ids" + :> MakesFederatedCall 'Brig "search-users" :> ZUser :> "search" :> "contacts" @@ -1086,6 +1124,7 @@ type MLSKeyPackageAPI = ( "self" :> Summary "Upload a fresh batch of key packages" :> Description "The request body should be a json object containing a list of base64-encoded key packages." + :> ZLocalUser :> CanThrow 'MLSProtocolError :> CanThrow 'MLSIdentityMismatch :> CaptureClientId "client" @@ -1096,6 +1135,8 @@ type MLSKeyPackageAPI = "mls-key-packages-claim" ( "claim" :> Summary "Claim one key package for each client of the given user" + :> MakesFederatedCall 'Brig "claim-key-packages" + :> ZLocalUser :> QualifiedCaptureUserId "user" :> QueryParam' [ Optional, @@ -1109,6 +1150,7 @@ type MLSKeyPackageAPI = :<|> Named "mls-key-packages-count" ( "self" + :> ZLocalUser :> CaptureClientId "client" :> "count" :> Summary "Return the number of unused key packages for the given client" @@ -1177,7 +1219,7 @@ type SearchAPI = (SearchResult TeamContact) ) -type MLSAPI = LiftNamed (ZLocalUser :> "mls" :> MLSKeyPackageAPI) +type MLSAPI = LiftNamed ("mls" :> MLSKeyPackageAPI) type AuthAPI = Named @@ -1189,6 +1231,7 @@ type AuthAPI = \ Every other combination is invalid.\ \ Access tokens can be given as query parameter or authorisation\ \ header, with the latter being preferred." + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> QueryParam "client_id" ClientId :> Cookies '["zuid" ::: SomeUserToken] :> Bearer SomeAccessToken @@ -1219,6 +1262,7 @@ type AuthAPI = ( "login" :> Summary "Authenticate a user to obtain a cookie and first access token" :> Description "Logins are throttled at the server's discretion" + :> MakesFederatedCall 'Brig "on-user-deleted-connections" :> ReqBody '[JSON] Login :> QueryParam' [ Optional, diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs b/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs index ea98f7f0ca..f31683711f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs @@ -30,6 +30,7 @@ import URI.ByteString import Wire.API.Asset import Wire.API.Error import Wire.API.Error.Cargohold +import Wire.API.MakesFederatedCall import Wire.API.Routes.AssetBody import Wire.API.Routes.MultiVerb import Wire.API.Routes.Public @@ -169,6 +170,8 @@ type QualifiedAPI = :> Description "**Note**: local assets result in a redirect, \ \while remote assets are streamed directly." + :> MakesFederatedCall 'Cargohold "get-asset" + :> MakesFederatedCall 'Cargohold "stream-asset" :> ZLocalUser :> "assets" :> "v4" @@ -276,6 +279,8 @@ type MainAPI = :> Description "**Note**: local assets result in a redirect, \ \while remote assets are streamed directly." + :> MakesFederatedCall 'Cargohold "get-asset" + :> MakesFederatedCall 'Cargohold "stream-asset" :> ZLocalUser :> "assets" :> QualifiedCapture "key" AssetKey diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index d0bdd13c0f..6450ee32a0 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -39,6 +39,7 @@ library Wire.API.Event.Team Wire.API.Internal.BulkPush Wire.API.Internal.Notification + Wire.API.MakesFederatedCall Wire.API.Message Wire.API.Message.Proto Wire.API.MLS.CipherSuite diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index f097910d74..900d716ce9 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -172,6 +172,13 @@ let sha256 = "1w23yz2iiayniymk7k4g8gww7268187cayw0c8m3bz2hbnvbyfbc"; }; }; + swagger2 = { + src = fetchgit { + url = "https://github.com/wireapp/swagger2"; + rev = "ba916df2775bb38ec603b726bbebfb65a908317a"; + sha256 = "sha256-IcsrJ5ur8Zm7Xp1PQBOb+2N7T8WMI8jJ6YuDv8ypsPQ="; + }; + }; cql-io = { src = fetchgit { url = "https://gitlab.com/axeman/cql-io"; diff --git a/services/brig/src/Brig/API.hs b/services/brig/src/Brig/API.hs index 0ec53a3734..f9bc09e4ed 100644 --- a/services/brig/src/Brig/API.hs +++ b/services/brig/src/Brig/API.hs @@ -32,7 +32,6 @@ import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import qualified Data.Swagger.Build.Api as Doc import Network.Wai.Routing (Routes) import Polysemy -import Wire.API.Federation.API import Wire.Sem.Concurrency sitemap :: @@ -46,8 +45,7 @@ sitemap :: PasswordResetStore, UserPendingActivationStore p ] - r, - CallsFed 'Brig "on-user-deleted-connections" + r ) => Routes Doc.ApiBuilder (Handler r) () sitemap = do diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 9c6ea8ed16..d01481190b 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -111,8 +111,7 @@ servantSitemap :: GalleyProvider, UserPendingActivationStore p ] - r, - CallsFed 'Brig "on-user-deleted-connections" + r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -158,13 +157,12 @@ accountAPI :: GalleyProvider, UserPendingActivationStore p ] - r, - CallsFed 'Brig "on-user-deleted-connections" + r ) => ServerT BrigIRoutes.AccountAPI (Handler r) accountAPI = - Named @"createUserNoVerify" createUserNoVerify - :<|> Named @"createUserNoVerifySpar" createUserNoVerifySpar + Named @"createUserNoVerify" (callsFed createUserNoVerify) + :<|> Named @"createUserNoVerifySpar" (callsFed createUserNoVerifySpar) teamsAPI :: ServerT BrigIRoutes.TeamsAPI (Handler r) teamsAPI = Named @"updateSearchVisibilityInbound" Index.updateSearchVisibilityInbound @@ -175,10 +173,10 @@ userAPI = :<|> deleteLocale :<|> getDefaultUserLocale -authAPI :: (Member GalleyProvider r, CallsFed 'Brig "on-user-deleted-connections") => ServerT BrigIRoutes.AuthAPI (Handler r) +authAPI :: (Member GalleyProvider r) => ServerT BrigIRoutes.AuthAPI (Handler r) authAPI = - Named @"legalhold-login" legalHoldLogin - :<|> Named @"sso-login" ssoLogin + Named @"legalhold-login" (callsFed legalHoldLogin) + :<|> Named @"sso-login" (callsFed ssoLogin) :<|> Named @"login-code" getLoginCode :<|> Named @"reauthenticate" reauthenticate @@ -297,11 +295,10 @@ sitemap :: GalleyProvider, UserPendingActivationStore p ] - r, - CallsFed 'Brig "on-user-deleted-connections" + r ) => Routes a (Handler r) () -sitemap = do +sitemap = unsafeCallsFed @'Brig @"on-user-deleted-connections" $ do get "/i/status" (continue $ const $ pure empty) true head "/i/status" (continue $ const $ pure empty) true diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 264ff7d456..4975a8b893 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -182,17 +182,7 @@ servantSitemap :: PublicKeyBundle, UserPendingActivationStore p ] - r, - CallsFed 'Brig "get-user-by-handle", - CallsFed 'Brig "get-users-by-ids", - CallsFed 'Brig "search-users", - CallsFed 'Brig "claim-key-packages", - CallsFed 'Brig "on-user-deleted-connections", - CallsFed 'Brig "claim-multi-prekey-bundle", - CallsFed 'Brig "send-connection-action", - CallsFed 'Brig "claim-prekey", - CallsFed 'Brig "claim-prekey-bundle", - CallsFed 'Brig "get-user-clients" + r ) => ServerT BrigAPI (Handler r) servantSitemap = @@ -214,35 +204,35 @@ servantSitemap = where userAPI :: ServerT UserAPI (Handler r) userAPI = - Named @"get-user-unqualified" getUserUnqualifiedH - :<|> Named @"get-user-qualified" getUser + Named @"get-user-unqualified" (callsFed getUserUnqualifiedH) + :<|> Named @"get-user-qualified" (callsFed getUser) :<|> Named @"update-user-email" updateUserEmail - :<|> Named @"get-handle-info-unqualified" getHandleInfoUnqualifiedH - :<|> Named @"get-user-by-handle-qualified" Handle.getHandleInfo - :<|> Named @"list-users-by-unqualified-ids-or-handles" listUsersByUnqualifiedIdsOrHandles - :<|> Named @"list-users-by-ids-or-handles" listUsersByIdsOrHandles + :<|> Named @"get-handle-info-unqualified" (callsFed getHandleInfoUnqualifiedH) + :<|> Named @"get-user-by-handle-qualified" (callsFed Handle.getHandleInfo) + :<|> Named @"list-users-by-unqualified-ids-or-handles" (callsFed listUsersByUnqualifiedIdsOrHandles) + :<|> Named @"list-users-by-ids-or-handles" (callsFed listUsersByIdsOrHandles) :<|> Named @"send-verification-code" sendVerificationCode :<|> Named @"get-rich-info" getRichInfo selfAPI :: ServerT SelfAPI (Handler r) selfAPI = Named @"get-self" getSelf - :<|> Named @"delete-self" deleteSelfUser - :<|> Named @"put-self" updateUser + :<|> Named @"delete-self" (callsFed deleteSelfUser) + :<|> Named @"put-self" (callsFed updateUser) :<|> Named @"change-phone" changePhone - :<|> Named @"remove-phone" removePhone - :<|> Named @"remove-email" removeEmail + :<|> Named @"remove-phone" (callsFed removePhone) + :<|> Named @"remove-email" (callsFed removeEmail) :<|> Named @"check-password-exists" checkPasswordExists :<|> Named @"change-password" changePassword - :<|> Named @"change-locale" changeLocale - :<|> Named @"change-handle" changeHandle + :<|> Named @"change-locale" (callsFed changeLocale) + :<|> Named @"change-handle" (callsFed changeHandle) accountAPI :: ServerT AccountAPI (Handler r) accountAPI = - Named @"register" createUser - :<|> Named @"verify-delete" verifyDeleteUser - :<|> Named @"get-activate" activate - :<|> Named @"post-activate" activateKey + Named @"register" (callsFed createUser) + :<|> Named @"verify-delete" (callsFed verifyDeleteUser) + :<|> Named @"get-activate" (callsFed activate) + :<|> Named @"post-activate" (callsFed activateKey) :<|> Named @"post-activate-send" sendActivationCode :<|> Named @"post-password-reset" beginPasswordReset :<|> Named @"post-password-reset-complete" completePasswordReset @@ -251,26 +241,26 @@ servantSitemap = clientAPI :: ServerT ClientAPI (Handler r) clientAPI = - Named @"get-user-clients-unqualified" getUserClientsUnqualified - :<|> Named @"get-user-clients-qualified" getUserClientsQualified - :<|> Named @"get-user-client-unqualified" getUserClientUnqualified - :<|> Named @"get-user-client-qualified" getUserClientQualified - :<|> Named @"list-clients-bulk" listClientsBulk - :<|> Named @"list-clients-bulk-v2" listClientsBulkV2 - :<|> Named @"list-clients-bulk@v2" listClientsBulkV2 + Named @"get-user-clients-unqualified" (callsFed getUserClientsUnqualified) + :<|> Named @"get-user-clients-qualified" (callsFed getUserClientsQualified) + :<|> Named @"get-user-client-unqualified" (callsFed getUserClientUnqualified) + :<|> Named @"get-user-client-qualified" (callsFed getUserClientQualified) + :<|> Named @"list-clients-bulk" (callsFed listClientsBulk) + :<|> Named @"list-clients-bulk-v2" (callsFed listClientsBulkV2) + :<|> Named @"list-clients-bulk@v2" (callsFed listClientsBulkV2) prekeyAPI :: ServerT PrekeyAPI (Handler r) prekeyAPI = - Named @"get-users-prekeys-client-unqualified" getPrekeyUnqualifiedH - :<|> Named @"get-users-prekeys-client-qualified" getPrekeyH - :<|> Named @"get-users-prekey-bundle-unqualified" getPrekeyBundleUnqualifiedH - :<|> Named @"get-users-prekey-bundle-qualified" getPrekeyBundleH + Named @"get-users-prekeys-client-unqualified" (callsFed getPrekeyUnqualifiedH) + :<|> Named @"get-users-prekeys-client-qualified" (callsFed getPrekeyH) + :<|> Named @"get-users-prekey-bundle-unqualified" (callsFed getPrekeyBundleUnqualifiedH) + :<|> Named @"get-users-prekey-bundle-qualified" (callsFed getPrekeyBundleH) :<|> Named @"get-multi-user-prekey-bundle-unqualified" getMultiUserPrekeyBundleUnqualifiedH - :<|> Named @"get-multi-user-prekey-bundle-qualified" getMultiUserPrekeyBundleH + :<|> Named @"get-multi-user-prekey-bundle-qualified" (callsFed getMultiUserPrekeyBundleH) userClientAPI :: ServerT UserClientAPI (Handler r) userClientAPI = - Named @"add-client" addClient + Named @"add-client" (callsFed addClient) :<|> Named @"update-client" updateClient :<|> Named @"delete-client" deleteClient :<|> Named @"list-clients" listClients @@ -283,15 +273,15 @@ servantSitemap = connectionAPI :: ServerT ConnectionAPI (Handler r) connectionAPI = - Named @"create-connection-unqualified" createConnectionUnqualified - :<|> Named @"create-connection" createConnection + Named @"create-connection-unqualified" (callsFed createConnectionUnqualified) + :<|> Named @"create-connection" (callsFed createConnection) :<|> Named @"list-local-connections" listLocalConnections :<|> Named @"list-connections" listConnections :<|> Named @"get-connection-unqualified" getLocalConnection :<|> Named @"get-connection" getConnection - :<|> Named @"update-connection-unqualified" updateLocalConnection - :<|> Named @"update-connection" updateConnection - :<|> Named @"search-contacts" Search.search + :<|> Named @"update-connection-unqualified" (callsFed updateLocalConnection) + :<|> Named @"update-connection" (callsFed updateConnection) + :<|> Named @"search-contacts" (callsFed Search.search) propertiesAPI :: ServerT PropertiesAPI (Handler r) propertiesAPI = @@ -306,7 +296,7 @@ servantSitemap = mlsAPI :: ServerT MLSAPI (Handler r) mlsAPI = Named @"mls-key-packages-upload" uploadKeyPackages - :<|> Named @"mls-key-packages-claim" claimKeyPackages + :<|> Named @"mls-key-packages-claim" (callsFed claimKeyPackages) :<|> Named @"mls-key-packages-count" countKeyPackages userHandleAPI :: ServerT UserHandleAPI (Handler r) @@ -320,9 +310,9 @@ servantSitemap = authAPI :: ServerT AuthAPI (Handler r) authAPI = - Named @"access" accessH + Named @"access" (callsFed accessH) :<|> Named @"send-login-code" sendLoginCode - :<|> Named @"login" login + :<|> Named @"login" (callsFed login) :<|> Named @"logout" logoutH :<|> Named @"change-self-email" changeSelfEmailH :<|> Named @"list-cookies" listCookies diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 62c7466098..bf440e2948 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -1,5 +1,4 @@ {-# LANGUAGE NumericUnderscores #-} -{-# OPTIONS_GHC -Wno-orphans #-} -- This file is part of the Wire Server implementation. -- @@ -81,10 +80,6 @@ import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import qualified Wire.Sem.Paging as P --- | Orphan instance to satisfy 'CallsFeds' constraints, which we otherwise use --- to track federation calls across the codebase. -instance {-# OVERLAPPING #-} CallsFed comp name - -- FUTUREWORK: If any of these async threads die, we will have no clue about it -- and brig could start misbehaving. We should ensure that brig dies whenever a -- thread terminates for any reason. @@ -97,7 +92,8 @@ run o = do Async.async $ runBrigToIO e $ wrapHttpClient $ - Queue.listen (e ^. internalEvents) Internal.onEvent + Queue.listen (e ^. internalEvents) $ + unsafeCallsFed @'Brig @"on-user-deleted-connections" Internal.onEvent let throttleMillis = fromMaybe defSqsThrottleMillis $ setSqsThrottleMillis (optSettings o) emailListener <- for (e ^. awsEnv . sesQueue) $ \q -> Async.async $ diff --git a/services/brig/test/integration/Util.hs b/services/brig/test/integration/Util.hs index db88fb0bf8..649195714d 100644 --- a/services/brig/test/integration/Util.hs +++ b/services/brig/test/integration/Util.hs @@ -172,7 +172,7 @@ unversioned r = runFedClient :: forall (name :: Symbol) comp api. - ( HasFedEndpoint comp api name, + ( HasUnsafeFedEndpoint comp api name, Servant.HasClient Servant.ClientM api ) => FedClient comp -> @@ -1297,7 +1297,7 @@ toServantResponse res = createWaiTestFedClient :: forall (name :: Symbol) comp api. - ( HasFedEndpoint comp api name, + ( HasUnsafeFedEndpoint comp api name, Servant.HasClient WaiTestFedClient api ) => Servant.Client WaiTestFedClient api diff --git a/services/cargohold/src/CargoHold/API/Public.hs b/services/cargohold/src/CargoHold/API/Public.hs index 18f85a9f71..67821235c9 100644 --- a/services/cargohold/src/CargoHold/API/Public.hs +++ b/services/cargohold/src/CargoHold/API/Public.hs @@ -40,7 +40,7 @@ import Wire.API.Routes.AssetBody import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold -servantSitemap :: (CallsFed 'Cargohold "get-asset", CallsFed 'Cargohold "stream-asset") => ServerT ServantAPI Handler +servantSitemap :: ServerT ServantAPI Handler servantSitemap = renewTokenV3 :<|> deleteTokenV3 @@ -58,12 +58,14 @@ servantSitemap = providerAPI :: forall tag. tag ~ 'ProviderPrincipalTag => ServerT (BaseAPIv3 tag) Handler providerAPI = uploadAssetV3 @tag :<|> downloadAssetV3 @tag :<|> deleteAssetV3 @tag legacyAPI = legacyDownloadPlain :<|> legacyDownloadPlain :<|> legacyDownloadOtr - qualifiedAPI = downloadAssetV4 :<|> deleteAssetV4 + qualifiedAPI :: ServerT QualifiedAPI Handler + qualifiedAPI = callsFed downloadAssetV4 :<|> deleteAssetV4 + mainAPI :: ServerT MainAPI Handler mainAPI = renewTokenV3 :<|> deleteTokenV3 :<|> uploadAssetV3 @'UserPrincipalTag - :<|> downloadAssetV4 + :<|> callsFed downloadAssetV4 :<|> deleteAssetV4 internalSitemap :: ServerT InternalAPI Handler diff --git a/services/cargohold/src/CargoHold/Run.hs b/services/cargohold/src/CargoHold/Run.hs index d0675dcf5a..09677b898e 100644 --- a/services/cargohold/src/CargoHold/Run.hs +++ b/services/cargohold/src/CargoHold/Run.hs @@ -15,7 +15,6 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . {-# LANGUAGE NumericUnderscores #-} -{-# OPTIONS_GHC -Wno-orphans #-} module CargoHold.Run ( run, @@ -51,7 +50,6 @@ import Servant.API import Servant.Server hiding (Handler, runHandler) import qualified UnliftIO.Async as Async import Util.Options -import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold @@ -59,8 +57,6 @@ import Wire.API.Routes.Version.Wai type CombinedAPI = FederationAPI :<|> ServantAPI :<|> InternalAPI -instance CallsFed comp name - run :: Opts -> IO () run o = lowerCodensity $ do (app, e) <- mkApp o diff --git a/services/cargohold/test/integration/API/Federation.hs b/services/cargohold/test/integration/API/Federation.hs index 686adc1aa8..6e25283ea0 100644 --- a/services/cargohold/test/integration/API/Federation.hs +++ b/services/cargohold/test/integration/API/Federation.hs @@ -83,7 +83,7 @@ testGetAssetAvailable isPublicAsset = do } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (fedClientIn @'Cargohold @"get-asset" ga) + gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is available liftIO $ ok @?= True @@ -103,7 +103,7 @@ testGetAssetNotAvailable = do } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (fedClientIn @'Cargohold @"get-asset" ga) + gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is not available liftIO $ ok @?= False @@ -130,7 +130,7 @@ testGetAssetWrongToken = do } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (fedClientIn @'Cargohold @"get-asset" ga) + gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is not available liftIO $ ok @?= False @@ -161,7 +161,7 @@ testLargeAsset = do gaKey = qUnqualified key } chunks <- withFederationClient $ do - source <- getAssetSource <$> runFederationClient (fedClientIn @'Cargohold @"stream-asset" ga) + source <- getAssetSource <$> runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) liftIO . runResourceT $ connect source sinkList liftIO $ do let minNumChunks = 8 @@ -193,7 +193,7 @@ testStreamAsset = do gaKey = qUnqualified key } respBody <- withFederationClient $ do - source <- getAssetSource <$> runFederationClient (fedClientIn @'Cargohold @"stream-asset" ga) + source <- getAssetSource <$> runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) liftIO . runResourceT $ connect source sinkLazy liftIO $ respBody @?= "Hello World" @@ -211,7 +211,7 @@ testStreamAssetNotAvailable = do gaKey = key } err <- withFederationError $ do - runFederationClient (fedClientIn @'Cargohold @"stream-asset" ga) + runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) liftIO $ do Wai.code err @?= HTTP.notFound404 Wai.label err @?= "not-found" @@ -237,7 +237,7 @@ testStreamAssetWrongToken = do gaKey = qUnqualified key } err <- withFederationError $ do - runFederationClient (fedClientIn @'Cargohold @"stream-asset" ga) + runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) liftIO $ do Wai.code err @?= HTTP.notFound404 Wai.label err @?= "not-found" From 0cdd40781b9982fcac747b1848280d4536fa9493 Mon Sep 17 00:00:00 2001 From: Sandy Maguire Date: Thu, 5 Jan 2023 00:39:03 -0800 Subject: [PATCH 08/33] Add MakesFederatedCall combinators to Galley (#2957) * feat: track federation api calls * chore: make format * fix: give a default instance for other packages * feat: galley callsfed tracking * chore: make format * fix: cargohold * chore: make format * doc: changelog.d * feat: MakesFederatedCall servant combinator * chore: make format * doc: haddock * fix: add RoutesToPaths instance * feat: use updated extension point for MakesFederatedCall * chore: make format * chore: remove spurious HasCallStack * feat: add some federated calls to brig * feat: federated calls in brig/client API * feat: more api calls * fix: add callsFed * feat: finish adding MakesFederatedCall documentation * chore: make format * feat: cargohold api * Add changelogs * Fix compilation of integration tests in Brig * Revert "Fix compilation of integration tests in Brig" This reverts commit 2310a32d780cdc624295a74a6af5be2f47733b55. * fix: clean up brig integration test callsites * feat: patch internal API * feat: conversation API * feat: many more galley apis * feat: finish API porting * fix: integration tests * doc: changelog * feat: SolveCallsFed for variadic numbers of callsFed * feat: remove extaneous calls to callsFed * chore: separate out ApplyMods --- changelog.d/5-internal/pr-2957 | 1 + .../src/Wire/API/Federation/API/Galley.hs | 66 ++++++++++++-- .../src/Wire/API/Federation/Endpoint.hs | 11 ++- libs/wire-api/src/Wire/API/ApplyMods.hs | 24 +++++ .../src/Wire/API/Routes/Public/Galley/Bot.hs | 5 +- .../API/Routes/Public/Galley/Conversation.hs | 70 ++++++++++++++ .../Wire/API/Routes/Public/Galley/Feature.hs | 28 +++--- .../API/Routes/Public/Galley/LegalHold.hs | 16 ++++ .../src/Wire/API/Routes/Public/Galley/MLS.hs | 25 ++++- .../API/Routes/Public/Galley/Messaging.hs | 6 ++ .../Routes/Public/Galley/TeamConversation.hs | 4 + libs/wire-api/wire-api.cabal | 1 + services/galley/src/Galley/API/Federation.hs | 24 ++--- services/galley/src/Galley/API/Internal.hs | 91 +++++++++++-------- services/galley/src/Galley/API/Public/Bot.hs | 8 +- .../src/Galley/API/Public/Conversation.hs | 71 ++++++--------- .../galley/src/Galley/API/Public/Feature.hs | 9 +- .../galley/src/Galley/API/Public/LegalHold.hs | 17 ++-- services/galley/src/Galley/API/Public/MLS.hs | 19 +--- .../galley/src/Galley/API/Public/Messaging.hs | 11 +-- .../galley/src/Galley/API/Public/Servant.hs | 21 +---- .../src/Galley/API/Public/TeamConversation.hs | 9 +- services/galley/test/integration/API.hs | 12 ++- .../test/integration/API/Federation/Util.hs | 4 + services/galley/test/integration/TestSetup.hs | 2 +- 25 files changed, 355 insertions(+), 200 deletions(-) create mode 100644 changelog.d/5-internal/pr-2957 create mode 100644 libs/wire-api/src/Wire/API/ApplyMods.hs diff --git a/changelog.d/5-internal/pr-2957 b/changelog.d/5-internal/pr-2957 new file mode 100644 index 0000000000..220d55e5f8 --- /dev/null +++ b/changelog.d/5-internal/pr-2957 @@ -0,0 +1 @@ +Add MakesFederatedCall combinators to Galley diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs index 90fc7de3e3..fb32aa2451 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs @@ -36,6 +36,7 @@ import Wire.API.Error.Galley import Wire.API.Federation.API.Common import Wire.API.Federation.Endpoint import Wire.API.MLS.SubConversation +import Wire.API.MakesFederatedCall import Wire.API.Message import Wire.API.Routes.Public.Galley.Messaging import Wire.API.Util.Aeson (CustomEncoded (..)) @@ -59,21 +60,72 @@ type GalleyApi = -- used by the backend that owns a conversation to inform this backend of -- changes to the conversation :<|> FedEndpoint "on-conversation-updated" ConversationUpdate () - :<|> FedEndpoint "leave-conversation" LeaveConversationRequest LeaveConversationResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation" + ] + "leave-conversation" + LeaveConversationRequest + LeaveConversationResponse -- used to notify this backend that a new message has been posted to a -- remote conversation :<|> FedEndpoint "on-message-sent" (RemoteMessage ConvId) () -- used by a remote backend to send a message to a conversation owned by -- this backend - :<|> FedEndpoint "send-message" ProteusMessageSendRequest MessageSendResponse - :<|> FedEndpoint "on-user-deleted-conversations" UserDeletedConversationsNotification EmptyResponse - :<|> FedEndpoint "update-conversation" ConversationUpdateRequest ConversationUpdateResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-message-sent", + MakesFederatedCall 'Brig "get-user-clients" + ] + "send-message" + ProteusMessageSendRequest + MessageSendResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-new-remote-conversation" + ] + "on-user-deleted-conversations" + UserDeletedConversationsNotification + EmptyResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation" + ] + "update-conversation" + ConversationUpdateRequest + ConversationUpdateResponse :<|> FedEndpoint "mls-welcome" MLSWelcomeRequest MLSWelcomeResponse :<|> FedEndpoint "on-mls-message-sent" RemoteMLSMessage RemoteMLSMessageResponse - :<|> FedEndpoint "send-mls-message" MLSMessageSendRequest MLSMessageResponse - :<|> FedEndpoint "send-mls-commit-bundle" MLSMessageSendRequest MLSMessageResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation", + MakesFederatedCall 'Galley "send-mls-message", + MakesFederatedCall 'Brig "get-mls-clients" + ] + "send-mls-message" + MLSMessageSendRequest + MLSMessageResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "mls-welcome", + MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation", + MakesFederatedCall 'Galley "send-mls-commit-bundle", + MakesFederatedCall 'Brig "get-mls-clients" + ] + "send-mls-commit-bundle" + MLSMessageSendRequest + MLSMessageResponse :<|> FedEndpoint "query-group-info" GetGroupInfoRequest GetGroupInfoResponse - :<|> FedEndpoint "on-client-removed" ClientRemovedRequest EmptyResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-mls-message-sent" + ] + "on-client-removed" + ClientRemovedRequest + EmptyResponse :<|> FedEndpoint "on-typing-indicator-updated" TypingDataUpdateRequest EmptyResponse data TypingDataUpdateRequest = TypingDataUpdateRequest diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs b/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs index cada1b4872..8c6367f249 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs @@ -15,16 +15,17 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.Federation.Endpoint where +module Wire.API.Federation.Endpoint + ( ApplyMods, + module Wire.API.Federation.Endpoint, + ) +where import Servant.API +import Wire.API.ApplyMods import Wire.API.Federation.Domain import Wire.API.Routes.Named -type family ApplyMods (mods :: [*]) api where - ApplyMods '[] api = api - ApplyMods (x ': xs) api = x :> ApplyMods xs api - type FedEndpointWithMods (mods :: [*]) name input output = Named name diff --git a/libs/wire-api/src/Wire/API/ApplyMods.hs b/libs/wire-api/src/Wire/API/ApplyMods.hs new file mode 100644 index 0000000000..ad65fdb28e --- /dev/null +++ b/libs/wire-api/src/Wire/API/ApplyMods.hs @@ -0,0 +1,24 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.ApplyMods where + +import Servant.API + +type family ApplyMods (mods :: [*]) api where + ApplyMods '[] api = api + ApplyMods (x ': xs) api = x :> ApplyMods xs api diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs index fddc356beb..2c4752fda4 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs @@ -21,6 +21,7 @@ import Servant hiding (WithStatus) import Servant.Swagger.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.MakesFederatedCall import Wire.API.Message import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named @@ -30,7 +31,9 @@ import Wire.API.Routes.Public.Galley.Messaging type BotAPI = Named "post-bot-message-unqualified" - ( ZBot + ( MakesFederatedCall 'Galley "on-message-sent" + :> MakesFederatedCall 'Brig "get-user-clients" + :> ZBot :> ZConversation :> CanThrow 'ConvNotFound :> "bot" diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index 023963c96c..3c877fe475 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -32,6 +32,7 @@ import Wire.API.Error.Galley import Wire.API.Event.Conversation import Wire.API.MLS.PublicGroupState import Wire.API.MLS.Servant +import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -124,6 +125,7 @@ type ConversationAPI = :<|> Named "get-conversation" ( Summary "Get a conversation by ID" + :> MakesFederatedCall 'Galley "get-conversations" :> CanThrow 'ConvNotFound :> CanThrow 'ConvAccessDenied :> ZLocalUser @@ -145,6 +147,7 @@ type ConversationAPI = :<|> Named "get-group-info" ( Summary "Get MLS group information" + :> MakesFederatedCall 'Galley "query-group-info" :> CanThrow 'ConvNotFound :> CanThrow 'MLSMissingGroupInfo :> CanThrow 'MLSNotEnabled @@ -251,6 +254,7 @@ type ConversationAPI = :<|> Named "list-conversations@v1" ( Summary "Get conversation metadata for a list of conversation ids" + :> MakesFederatedCall 'Galley "get-conversations" :> Until 'V2 :> ZLocalUser :> "conversations" @@ -262,6 +266,7 @@ type ConversationAPI = :<|> Named "list-conversations@v2" ( Summary "Get conversation metadata for a list of conversation ids" + :> MakesFederatedCall 'Galley "get-conversations" :> From 'V2 :> Until 'V3 :> ZLocalUser @@ -281,6 +286,7 @@ type ConversationAPI = :<|> Named "list-conversations" ( Summary "Get conversation metadata for a list of conversation ids" + :> MakesFederatedCall 'Galley "get-conversations" :> From 'V3 :> ZLocalUser :> "conversations" @@ -308,6 +314,7 @@ type ConversationAPI = :<|> Named "create-group-conversation@v2" ( Summary "Create a new conversation" + :> MakesFederatedCall 'Galley "on-conversation-created" :> Until 'V3 :> CanThrow 'ConvAccessDenied :> CanThrow 'MLSNonEmptyMemberList @@ -326,6 +333,7 @@ type ConversationAPI = :<|> Named "create-group-conversation" ( Summary "Create a new conversation" + :> MakesFederatedCall 'Galley "on-conversation-created" :> From 'V3 :> CanThrow 'ConvAccessDenied :> CanThrow 'MLSNonEmptyMemberList @@ -381,6 +389,7 @@ type ConversationAPI = :<|> Named "create-one-to-one-conversation@v2" ( Summary "Create a 1:1 conversation" + :> MakesFederatedCall 'Galley "on-conversation-created" :> Until 'V3 :> CanThrow 'ConvAccessDenied :> CanThrow 'InvalidOperation @@ -401,6 +410,7 @@ type ConversationAPI = :<|> Named "create-one-to-one-conversation" ( Summary "Create a 1:1 conversation" + :> MakesFederatedCall 'Galley "on-conversation-created" :> From 'V3 :> CanThrow 'ConvAccessDenied :> CanThrow 'InvalidOperation @@ -423,6 +433,9 @@ type ConversationAPI = :<|> Named "add-members-to-conversation-unqualified" ( Summary "Add members to an existing conversation (deprecated)" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> Until 'V2 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) @@ -444,6 +457,9 @@ type ConversationAPI = :<|> Named "add-members-to-conversation-unqualified2" ( Summary "Add qualified members to an existing conversation." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> Until 'V2 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) @@ -466,6 +482,9 @@ type ConversationAPI = :<|> Named "add-members-to-conversation" ( Summary "Add qualified members to an existing conversation." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> From 'V2 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) @@ -489,6 +508,8 @@ type ConversationAPI = :<|> Named "join-conversation-by-id-unqualified" ( Summary "Join a conversation by its ID (if link access enabled)" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -509,6 +530,8 @@ type ConversationAPI = "Join a conversation using a reusable code.\ \If the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.\ \Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow 'CodeNotFound :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvNotFound @@ -620,6 +643,7 @@ type ConversationAPI = :<|> Named "member-typing-qualified" ( Summary "Sending typing notifications" + :> MakesFederatedCall 'Galley "on-typing-indicator-updated" :> CanThrow 'ConvNotFound :> ZLocalUser :> ZConn @@ -634,6 +658,10 @@ type ConversationAPI = :<|> Named "remove-member-unqualified" ( Summary "Remove a member from a conversation (deprecated)" + :> MakesFederatedCall 'Galley "leave-conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> Until 'V2 :> ZLocalUser :> ZConn @@ -651,6 +679,10 @@ type ConversationAPI = :<|> Named "remove-member" ( Summary "Remove a member from a conversation" + :> MakesFederatedCall 'Galley "leave-conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'RemoveConversationMember) @@ -668,6 +700,9 @@ type ConversationAPI = "update-other-member-unqualified" ( Summary "Update membership of the specified user (deprecated)" :> Description "Use `PUT /conversations/:cnv_domain/:cnv/members/:usr_domain/:usr` instead" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> ZLocalUser :> ZConn :> CanThrow 'ConvNotFound @@ -690,6 +725,9 @@ type ConversationAPI = "update-other-member" ( Summary "Update membership of the specified user" :> Description "**Note**: at least one field has to be provided." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> ZLocalUser :> ZConn :> CanThrow 'ConvNotFound @@ -714,6 +752,9 @@ type ConversationAPI = "update-conversation-name-deprecated" ( Summary "Update conversation name (deprecated)" :> Description "Use `/conversations/:domain/:conv/name` instead." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -732,6 +773,9 @@ type ConversationAPI = "update-conversation-name-unqualified" ( Summary "Update conversation name (deprecated)" :> Description "Use `/conversations/:domain/:conv/name` instead." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -750,6 +794,9 @@ type ConversationAPI = :<|> Named "update-conversation-name" ( Summary "Update conversation name" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -771,6 +818,9 @@ type ConversationAPI = "update-conversation-message-timer-unqualified" ( Summary "Update the message timer for a conversation (deprecated)" :> Description "Use `/conversations/:domain/:cnv/message-timer` instead." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationMessageTimer) @@ -790,6 +840,9 @@ type ConversationAPI = :<|> Named "update-conversation-message-timer" ( Summary "Update the message timer for a conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationMessageTimer) @@ -812,6 +865,10 @@ type ConversationAPI = "update-conversation-receipt-mode-unqualified" ( Summary "Update receipt mode for a conversation (deprecated)" :> Description "Use `PUT /conversations/:domain/:cnv/receipt-mode` instead." + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" + :> MakesFederatedCall 'Galley "update-conversation" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationReceiptMode) @@ -831,6 +888,10 @@ type ConversationAPI = :<|> Named "update-conversation-receipt-mode" ( Summary "Update receipt mode for a conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" + :> MakesFederatedCall 'Galley "update-conversation" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationReceiptMode) @@ -853,6 +914,9 @@ type ConversationAPI = :<|> Named "update-conversation-access-unqualified" ( Summary "Update access modes for a conversation (deprecated)" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> Until 'V3 :> Description "Use PUT `/conversations/:domain/:cnv/access` instead." :> ZLocalUser @@ -876,6 +940,9 @@ type ConversationAPI = :<|> Named "update-conversation-access@v2" ( Summary "Update access modes for a conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> Until 'V3 :> ZLocalUser :> ZConn @@ -898,6 +965,9 @@ type ConversationAPI = :<|> Named "update-conversation-access" ( Summary "Update access modes for a conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> From 'V3 :> ZLocalUser :> ZConn diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index f52fd7b183..853884e30f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -21,9 +21,11 @@ import Data.Id import GHC.TypeLits import Servant hiding (WithStatus) import Servant.Swagger.Internal.Orphans () +import Wire.API.ApplyMods import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -35,6 +37,10 @@ type FeatureAPI = FeatureStatusGet SSOConfig :<|> FeatureStatusGet LegalholdConfig :<|> FeatureStatusPut + '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation" + ] '( 'ActionDenied 'RemoveConversationMember, '( AuthenticationError, '( 'CannotEnableLegalHoldServiceLargeTeam, @@ -52,7 +58,7 @@ type FeatureAPI = ) LegalholdConfig :<|> FeatureStatusGet SearchVisibilityAvailableConfig - :<|> FeatureStatusPut '() SearchVisibilityAvailableConfig + :<|> FeatureStatusPut '[] '() SearchVisibilityAvailableConfig :<|> FeatureStatusDeprecatedGet "This endpoint is potentially used by the old Android client. It is not used by iOS, team management, or webapp as of June 2022" SearchVisibilityAvailableConfig :<|> FeatureStatusDeprecatedPut "This endpoint is potentially used by the old Android client. It is not used by iOS, team management, or webapp as of June 2022" SearchVisibilityAvailableConfig :<|> SearchVisibilityGet @@ -62,23 +68,23 @@ type FeatureAPI = :<|> FeatureStatusGet DigitalSignaturesConfig :<|> FeatureStatusDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is potentially used by the old Android client. It is not used by team management, or webapp as of June 2022" DigitalSignaturesConfig :<|> FeatureStatusGet AppLockConfig - :<|> FeatureStatusPut '() AppLockConfig + :<|> FeatureStatusPut '[] '() AppLockConfig :<|> FeatureStatusGet FileSharingConfig - :<|> FeatureStatusPut '() FileSharingConfig + :<|> FeatureStatusPut '[] '() FileSharingConfig :<|> FeatureStatusGet ClassifiedDomainsConfig :<|> FeatureStatusGet ConferenceCallingConfig :<|> FeatureStatusGet SelfDeletingMessagesConfig - :<|> FeatureStatusPut '() SelfDeletingMessagesConfig + :<|> FeatureStatusPut '[] '() SelfDeletingMessagesConfig :<|> FeatureStatusGet GuestLinksConfig - :<|> FeatureStatusPut '() GuestLinksConfig + :<|> FeatureStatusPut '[] '() GuestLinksConfig :<|> FeatureStatusGet SndFactorPasswordChallengeConfig - :<|> FeatureStatusPut '() SndFactorPasswordChallengeConfig + :<|> FeatureStatusPut '[] '() SndFactorPasswordChallengeConfig :<|> FeatureStatusGet MLSConfig - :<|> FeatureStatusPut '() MLSConfig + :<|> FeatureStatusPut '[] '() MLSConfig :<|> FeatureStatusGet ExposeInvitationURLsToTeamAdminConfig - :<|> FeatureStatusPut '() ExposeInvitationURLsToTeamAdminConfig + :<|> FeatureStatusPut '[] '() ExposeInvitationURLsToTeamAdminConfig :<|> FeatureStatusGet SearchVisibilityInboundConfig - :<|> FeatureStatusPut '() SearchVisibilityInboundConfig + :<|> FeatureStatusPut '[] '() SearchVisibilityInboundConfig :<|> AllFeatureConfigsUserGet :<|> AllFeatureConfigsTeamGet :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is not used by team management, or webapp, and is potentially used by the old Android client as of June 2022" LegalholdConfig @@ -100,10 +106,10 @@ type FeatureStatusGet f = '("get", f) (ZUser :> FeatureStatusBaseGet f) -type FeatureStatusPut errs f = +type FeatureStatusPut segs errs f = Named '("put", f) - (ZUser :> FeatureStatusBasePutPublic errs f) + (ApplyMods segs (ZUser :> FeatureStatusBasePutPublic errs f)) type FeatureStatusDeprecatedGet d f = Named diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs index 0c1ae5b2f1..82318d9213 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs @@ -25,6 +25,7 @@ import Servant.Swagger.Internal.Orphans () import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -62,6 +63,9 @@ type LegalHoldAPI = :<|> Named "delete-legal-hold-settings" ( Summary "Delete legal hold service settings" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow AuthenticationError :> CanThrow OperationDenied :> CanThrow 'NotATeamMember @@ -98,6 +102,9 @@ type LegalHoldAPI = :<|> Named "consent-to-legal-hold" ( Summary "Consent to legal hold" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'InvalidOperation :> CanThrow 'TeamMemberNotFound @@ -113,6 +120,9 @@ type LegalHoldAPI = :<|> Named "request-legal-hold-device" ( Summary "Request legal hold device" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'NotATeamMember :> CanThrow OperationDenied @@ -141,6 +151,9 @@ type LegalHoldAPI = :<|> Named "disable-legal-hold-for-user" ( Summary "Disable legal hold for user" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow AuthenticationError :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'NotATeamMember @@ -167,6 +180,9 @@ type LegalHoldAPI = :<|> Named "approve-legal-hold-device" ( Summary "Approve legal hold device" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow AuthenticationError :> CanThrow 'AccessDenied :> CanThrow ('ActionDenied 'RemoveConversationMember) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs index 09dbc3c77d..2d6a25e5b0 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs @@ -28,6 +28,7 @@ import Wire.API.MLS.Message import Wire.API.MLS.Serialisation import Wire.API.MLS.Servant import Wire.API.MLS.Welcome +import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -37,9 +38,11 @@ type MLSMessagingAPI = Named "mls-welcome-message" ( Summary "Post an MLS welcome message" + :> MakesFederatedCall 'Galley "mls-welcome" :> CanThrow 'MLSKeyPackageRefNotFound :> CanThrow 'MLSNotEnabled :> "welcome" + :> ZLocalUser :> ZConn :> ReqBody '[MLS] (RawMLS Welcome) :> MultiVerb1 'POST '[JSON] (RespondEmpty 201 "Welcome message sent") @@ -47,6 +50,11 @@ type MLSMessagingAPI = :<|> Named "mls-message-v1" ( Summary "Post an MLS message" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "send-mls-message" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" + :> MakesFederatedCall 'Brig "get-mls-clients" :> Until 'V2 :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvMemberNotFound @@ -68,6 +76,7 @@ type MLSMessagingAPI = :> CanThrow 'MissingLegalholdConsent :> CanThrow MLSProposalFailure :> "messages" + :> ZLocalUser :> ZOptClient :> ZConn :> ReqBody '[MLS] (RawMLS SomeMessage) @@ -76,6 +85,11 @@ type MLSMessagingAPI = :<|> Named "mls-message" ( Summary "Post an MLS message" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "send-mls-message" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" + :> MakesFederatedCall 'Brig "get-mls-clients" :> From 'V2 :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvMemberNotFound @@ -97,6 +111,7 @@ type MLSMessagingAPI = :> CanThrow 'MissingLegalholdConsent :> CanThrow MLSProposalFailure :> "messages" + :> ZLocalUser :> ZOptClient :> ZConn :> ReqBody '[MLS] (RawMLS SomeMessage) @@ -105,6 +120,12 @@ type MLSMessagingAPI = :<|> Named "mls-commit-bundle" ( Summary "Post a MLS CommitBundle" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "mls-welcome" + :> MakesFederatedCall 'Galley "send-mls-commit-bundle" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" + :> MakesFederatedCall 'Brig "get-mls-clients" :> From 'V3 :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvMemberNotFound @@ -127,6 +148,7 @@ type MLSMessagingAPI = :> CanThrow 'MissingLegalholdConsent :> CanThrow MLSProposalFailure :> "commit-bundles" + :> ZLocalUser :> ZOptClient :> ZConn :> ReqBody '[CommitBundleMimeType] CommitBundle @@ -137,7 +159,8 @@ type MLSMessagingAPI = ( Summary "Get public keys used by the backend to sign external proposals" :> CanThrow 'MLSNotEnabled :> "public-keys" + :> ZLocalUser :> MultiVerb1 'GET '[JSON] (Respond 200 "Public keys" MLSPublicKeys) ) -type MLSAPI = LiftNamed (ZLocalUser :> "mls" :> MLSMessagingAPI) +type MLSAPI = LiftNamed ("mls" :> MLSMessagingAPI) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs index 1e982f96e6..eb2f408dd5 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs @@ -26,6 +26,7 @@ import Servant.Swagger.Internal.Orphans () import Wire.API.Error import qualified Wire.API.Error.Brig as BrigError import Wire.API.Error.Galley +import Wire.API.MakesFederatedCall import Wire.API.Message import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named @@ -38,6 +39,8 @@ type MessagingAPI = "post-otr-message-unqualified" ( Summary "Post an encrypted message to a conversation (accepts JSON or Protobuf)" :> Description PostOtrDescriptionUnqualified + :> MakesFederatedCall 'Galley "on-message-sent" + :> MakesFederatedCall 'Brig "get-user-clients" :> ZLocalUser :> ZConn :> "conversations" @@ -78,6 +81,9 @@ type MessagingAPI = "post-proteus-message" ( Summary "Post an encrypted message to a conversation (accepts only Protobuf)" :> Description PostOtrDescription + :> MakesFederatedCall 'Brig "get-user-clients" + :> MakesFederatedCall 'Galley "on-message-sent" + :> MakesFederatedCall 'Galley "send-message" :> ZLocalUser :> ZConn :> "conversations" diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs index ce00269f8a..76753f48f2 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs @@ -23,6 +23,7 @@ import Servant.Swagger.Internal.Orphans () import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -67,6 +68,9 @@ type TeamConversationAPI = :<|> Named "delete-team-conversation" ( Summary "Remove a team conversation" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "on-new-remote-conversation" :> CanThrow ('ActionDenied 'DeleteConversation) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 6450ee32a0..843da12233 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -13,6 +13,7 @@ build-type: Simple library -- cabal-fmt: expand src exposed-modules: + Wire.API.ApplyMods Wire.API.Asset Wire.API.Call.Config Wire.API.Connection diff --git a/services/galley/src/Galley/API/Federation.hs b/services/galley/src/Galley/API/Federation.hs index d425e11331..0572690474 100644 --- a/services/galley/src/Galley/API/Federation.hs +++ b/services/galley/src/Galley/API/Federation.hs @@ -101,33 +101,23 @@ type FederationAPI = "federation" :> FedApi 'Galley -- | Convert a polysemy handler to an 'API' value. federationSitemap :: - ( CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Brig "get-mls-clients", - CallsFed 'Galley "on-new-remote-conversation", - CallsFed 'Galley "send-mls-message", - CallsFed 'Galley "mls-welcome", - CallsFed 'Galley "send-mls-commit-bundle", - CallsFed 'Galley "on-message-sent", - CallsFed 'Brig "get-user-clients" - ) => ServerT FederationAPI (Sem GalleyEffects) federationSitemap = Named @"on-conversation-created" onConversationCreated :<|> Named @"on-new-remote-conversation" onNewRemoteConversation :<|> Named @"get-conversations" getConversations :<|> Named @"on-conversation-updated" onConversationUpdated - :<|> Named @"leave-conversation" leaveConversation + :<|> Named @"leave-conversation" (callsFed leaveConversation) :<|> Named @"on-message-sent" onMessageSent - :<|> Named @"send-message" sendMessage - :<|> Named @"on-user-deleted-conversations" onUserDeleted - :<|> Named @"update-conversation" updateConversation + :<|> Named @"send-message" (callsFed sendMessage) + :<|> Named @"on-user-deleted-conversations" (callsFed onUserDeleted) + :<|> Named @"update-conversation" (callsFed updateConversation) :<|> Named @"mls-welcome" mlsSendWelcome :<|> Named @"on-mls-message-sent" onMLSMessageSent - :<|> Named @"send-mls-message" sendMLSMessage - :<|> Named @"send-mls-commit-bundle" sendMLSCommitBundle + :<|> Named @"send-mls-message" (callsFed sendMLSMessage) + :<|> Named @"send-mls-commit-bundle" (callsFed sendMLSCommitBundle) :<|> Named @"query-group-info" queryGroupInfo - :<|> Named @"on-client-removed" onClientRemoved + :<|> Named @"on-client-removed" (callsFed onClientRemoved) :<|> Named @"on-typing-indicator-updated" onTypingIndicatorUpdated onClientRemoved :: diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index f3ac4a2b00..972248483c 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -14,7 +14,6 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -{-# OPTIONS_GHC -fno-warn-orphans #-} module Galley.API.Internal ( internalSitemap, @@ -87,6 +86,7 @@ import Servant hiding (JSON, WithStatus) import qualified Servant hiding (WithStatus) import System.Logger.Class hiding (Path, name) import qualified System.Logger.Class as Log +import Wire.API.ApplyMods import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Action import Wire.API.Conversation.Role @@ -113,8 +113,6 @@ import Wire.API.Team.SearchVisibility import Wire.Sem.Paging import Wire.Sem.Paging.Cassandra -instance CallsFed comp name - type LegalHoldFeatureStatusChangeErrors = '( 'ActionDenied 'RemoveConversationMember, '( AuthenticationError, @@ -132,78 +130,86 @@ type LegalHoldFeatureStatusChangeErrors = ) ) +type LegalHoldFeaturesStatusChangeFederatedCalls = + '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-new-remote-conversation" + ] + type IFeatureAPI = -- SSOConfig IFeatureStatusGet SSOConfig - :<|> IFeatureStatusPut '() SSOConfig - :<|> IFeatureStatusPatch '() SSOConfig + :<|> IFeatureStatusPut '[] '() SSOConfig + :<|> IFeatureStatusPatch '[] '() SSOConfig -- LegalholdConfig :<|> IFeatureStatusGet LegalholdConfig :<|> IFeatureStatusPut + LegalHoldFeaturesStatusChangeFederatedCalls LegalHoldFeatureStatusChangeErrors LegalholdConfig :<|> IFeatureStatusPatch + LegalHoldFeaturesStatusChangeFederatedCalls LegalHoldFeatureStatusChangeErrors LegalholdConfig -- SearchVisibilityAvailableConfig :<|> IFeatureStatusGet SearchVisibilityAvailableConfig - :<|> IFeatureStatusPut '() SearchVisibilityAvailableConfig - :<|> IFeatureStatusPatch '() SearchVisibilityAvailableConfig + :<|> IFeatureStatusPut '[] '() SearchVisibilityAvailableConfig + :<|> IFeatureStatusPatch '[] '() SearchVisibilityAvailableConfig -- ValidateSAMLEmailsConfig :<|> IFeatureStatusGet ValidateSAMLEmailsConfig - :<|> IFeatureStatusPut '() ValidateSAMLEmailsConfig - :<|> IFeatureStatusPatch '() ValidateSAMLEmailsConfig + :<|> IFeatureStatusPut '[] '() ValidateSAMLEmailsConfig + :<|> IFeatureStatusPatch '[] '() ValidateSAMLEmailsConfig -- DigitalSignaturesConfig :<|> IFeatureStatusGet DigitalSignaturesConfig - :<|> IFeatureStatusPut '() DigitalSignaturesConfig - :<|> IFeatureStatusPatch '() DigitalSignaturesConfig + :<|> IFeatureStatusPut '[] '() DigitalSignaturesConfig + :<|> IFeatureStatusPatch '[] '() DigitalSignaturesConfig -- AppLockConfig :<|> IFeatureStatusGet AppLockConfig - :<|> IFeatureStatusPut '() AppLockConfig - :<|> IFeatureStatusPatch '() AppLockConfig + :<|> IFeatureStatusPut '[] '() AppLockConfig + :<|> IFeatureStatusPatch '[] '() AppLockConfig -- FileSharingConfig :<|> IFeatureStatusGet FileSharingConfig - :<|> IFeatureStatusPut '() FileSharingConfig + :<|> IFeatureStatusPut '[] '() FileSharingConfig :<|> IFeatureStatusLockStatusPut FileSharingConfig - :<|> IFeatureStatusPatch '() FileSharingConfig + :<|> IFeatureStatusPatch '[] '() FileSharingConfig -- ConferenceCallingConfig :<|> IFeatureStatusGet ConferenceCallingConfig - :<|> IFeatureStatusPut '() ConferenceCallingConfig - :<|> IFeatureStatusPatch '() ConferenceCallingConfig + :<|> IFeatureStatusPut '[] '() ConferenceCallingConfig + :<|> IFeatureStatusPatch '[] '() ConferenceCallingConfig -- SelfDeletingMessagesConfig :<|> IFeatureStatusGet SelfDeletingMessagesConfig - :<|> IFeatureStatusPut '() SelfDeletingMessagesConfig + :<|> IFeatureStatusPut '[] '() SelfDeletingMessagesConfig :<|> IFeatureStatusLockStatusPut SelfDeletingMessagesConfig - :<|> IFeatureStatusPatch '() SelfDeletingMessagesConfig + :<|> IFeatureStatusPatch '[] '() SelfDeletingMessagesConfig -- GuestLinksConfig :<|> IFeatureStatusGet GuestLinksConfig - :<|> IFeatureStatusPut '() GuestLinksConfig + :<|> IFeatureStatusPut '[] '() GuestLinksConfig :<|> IFeatureStatusLockStatusPut GuestLinksConfig - :<|> IFeatureStatusPatch '() GuestLinksConfig + :<|> IFeatureStatusPatch '[] '() GuestLinksConfig -- SndFactorPasswordChallengeConfig :<|> IFeatureStatusGet SndFactorPasswordChallengeConfig - :<|> IFeatureStatusPut '() SndFactorPasswordChallengeConfig + :<|> IFeatureStatusPut '[] '() SndFactorPasswordChallengeConfig :<|> IFeatureStatusLockStatusPut SndFactorPasswordChallengeConfig - :<|> IFeatureStatusPatch '() SndFactorPasswordChallengeConfig + :<|> IFeatureStatusPatch '[] '() SndFactorPasswordChallengeConfig -- SearchVisibilityInboundConfig :<|> IFeatureStatusGet SearchVisibilityInboundConfig - :<|> IFeatureStatusPut '() SearchVisibilityInboundConfig - :<|> IFeatureStatusPatch '() SearchVisibilityInboundConfig + :<|> IFeatureStatusPut '[] '() SearchVisibilityInboundConfig + :<|> IFeatureStatusPatch '[] '() SearchVisibilityInboundConfig :<|> IFeatureNoConfigMultiGet SearchVisibilityInboundConfig -- ClassifiedDomainsConfig :<|> IFeatureStatusGet ClassifiedDomainsConfig -- MLSConfig :<|> IFeatureStatusGet MLSConfig - :<|> IFeatureStatusPut '() MLSConfig - :<|> IFeatureStatusPatch '() MLSConfig + :<|> IFeatureStatusPut '[] '() MLSConfig + :<|> IFeatureStatusPatch '[] '() MLSConfig -- ExposeInvitationURLsToTeamAdminConfig :<|> IFeatureStatusGet ExposeInvitationURLsToTeamAdminConfig - :<|> IFeatureStatusPut '() ExposeInvitationURLsToTeamAdminConfig - :<|> IFeatureStatusPatch '() ExposeInvitationURLsToTeamAdminConfig + :<|> IFeatureStatusPut '[] '() ExposeInvitationURLsToTeamAdminConfig + :<|> IFeatureStatusPatch '[] '() ExposeInvitationURLsToTeamAdminConfig -- SearchVisibilityInboundConfig :<|> IFeatureStatusGet SearchVisibilityInboundConfig - :<|> IFeatureStatusPut '() SearchVisibilityInboundConfig - :<|> IFeatureStatusPatch '() SearchVisibilityInboundConfig + :<|> IFeatureStatusPut '[] '() SearchVisibilityInboundConfig + :<|> IFeatureStatusPatch '[] '() SearchVisibilityInboundConfig -- all feature configs :<|> Named "feature-configs-internal" @@ -235,6 +241,9 @@ type InternalAPIBase = "delete-user" ( Summary "Remove a user from their teams and conversations and erase their clients" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Galley "on-user-deleted-conversations" + :> MakesFederatedCall 'Galley "on-mls-message-sent" :> ZLocalUser :> ZOptConn :> "user" @@ -246,6 +255,7 @@ type InternalAPIBase = :<|> Named "connect" ( Summary "Create a connect conversation (deprecated)" + :> MakesFederatedCall 'Galley "on-conversation-created" :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation :> CanThrow 'NotConnected @@ -396,9 +406,9 @@ type ITeamsAPIBase = type IFeatureStatusGet f = Named '("iget", f) (FeatureStatusBaseGet f) -type IFeatureStatusPut errs f = Named '("iput", f) (FeatureStatusBasePutInternal errs f) +type IFeatureStatusPut calls errs f = Named '("iput", f) (ApplyMods calls (FeatureStatusBasePutInternal errs f)) -type IFeatureStatusPatch errs f = Named '("ipatch", f) (FeatureStatusBasePatchInternal errs f) +type IFeatureStatusPatch calls errs f = Named '("ipatch", f) (ApplyMods calls (FeatureStatusBasePatchInternal errs f)) type FeatureStatusBasePutInternal errs featureConfig = FeatureStatusBaseInternal @@ -462,8 +472,8 @@ internalAPI :: API InternalAPI GalleyEffects internalAPI = hoistAPI @InternalAPIBase id $ mkNamedAPI @"status" (pure ()) - <@> mkNamedAPI @"delete-user" rmUser - <@> mkNamedAPI @"connect" Create.createConnectConversation + <@> mkNamedAPI @"delete-user" (callsFed rmUser) + <@> mkNamedAPI @"connect" (callsFed Create.createConnectConversation) <@> mkNamedAPI @"guard-legalhold-policy-conflicts" guardLegalholdPolicyConflictsH <@> legalholdWhitelistedTeamsAPI <@> iTeamsAPI @@ -514,8 +524,8 @@ featureAPI = <@> mkNamedAPI @'("iput", SSOConfig) (setFeatureStatusInternal @Cassandra) <@> mkNamedAPI @'("ipatch", SSOConfig) (patchFeatureStatusInternal @Cassandra) <@> mkNamedAPI @'("iget", LegalholdConfig) (getFeatureStatus @Cassandra DontDoAuth) - <@> mkNamedAPI @'("iput", LegalholdConfig) (setFeatureStatusInternal @Cassandra) - <@> mkNamedAPI @'("ipatch", LegalholdConfig) (patchFeatureStatusInternal @Cassandra) + <@> mkNamedAPI @'("iput", LegalholdConfig) (callsFed (setFeatureStatusInternal @Cassandra)) + <@> mkNamedAPI @'("ipatch", LegalholdConfig) (callsFed (patchFeatureStatusInternal @Cassandra)) <@> mkNamedAPI @'("iget", SearchVisibilityAvailableConfig) (getFeatureStatus @Cassandra DontDoAuth) <@> mkNamedAPI @'("iput", SearchVisibilityAvailableConfig) (setFeatureStatusInternal @Cassandra) <@> mkNamedAPI @'("ipatch", SearchVisibilityAvailableConfig) (patchFeatureStatusInternal @Cassandra) @@ -564,7 +574,7 @@ featureAPI = <@> mkNamedAPI @"feature-configs-internal" (maybe (getAllFeatureConfigsForServer @Cassandra) (getAllFeatureConfigsForUser @Cassandra)) internalSitemap :: Routes a (Sem GalleyEffects) () -internalSitemap = do +internalSitemap = unsafeCallsFed @'Galley @"on-client-removed" $ unsafeCallsFed @'Galley @"on-mls-message-sent" $ do -- Conversation API (internal) ---------------------------------------- put "/i/conversations/:cnv/channel" (continue $ const (pure empty)) $ zauthUserId @@ -671,7 +681,10 @@ rmUser :: P.TinyLog, TeamStore ] - r + r, + CallsFed 'Galley "on-conversation-updated", + CallsFed 'Galley "on-user-deleted-conversations", + CallsFed 'Galley "on-mls-message-sent" ) => Local UserId -> Maybe ConnId -> diff --git a/services/galley/src/Galley/API/Public/Bot.hs b/services/galley/src/Galley/API/Public/Bot.hs index 55e6bbaf09..06ea1f89fa 100644 --- a/services/galley/src/Galley/API/Public/Bot.hs +++ b/services/galley/src/Galley/API/Public/Bot.hs @@ -23,9 +23,5 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Bot -botAPI :: - ( CallsFed 'Galley "on-message-sent", - CallsFed 'Brig "get-user-clients" - ) => - API BotAPI GalleyEffects -botAPI = mkNamedAPI @"post-bot-message-unqualified" postBotMessageUnqualified +botAPI :: API BotAPI GalleyEffects +botAPI = mkNamedAPI @"post-bot-message-unqualified" (callsFed (callsFed postBotMessageUnqualified)) diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index dbdc90591e..c080d83b04 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -28,65 +28,54 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Conversation -conversationAPI :: - ( CallsFed 'Galley "get-conversations", - CallsFed 'Galley "query-group-info", - CallsFed 'Galley "on-typing-indicator-updated", - CallsFed 'Galley "on-conversation-created", - CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-new-remote-conversation", - CallsFed 'Galley "leave-conversation", - CallsFed 'Galley "update-conversation" - ) => - API ConversationAPI GalleyEffects +conversationAPI :: API ConversationAPI GalleyEffects conversationAPI = mkNamedAPI @"get-unqualified-conversation" getUnqualifiedConversation <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" getUnqualifiedConversation - <@> mkNamedAPI @"get-conversation" getConversation + <@> mkNamedAPI @"get-conversation" (callsFed getConversation) <@> mkNamedAPI @"get-conversation-roles" getConversationRoles - <@> mkNamedAPI @"get-group-info" getGroupInfo + <@> mkNamedAPI @"get-group-info" (callsFed getGroupInfo) <@> mkNamedAPI @"list-conversation-ids-unqualified" conversationIdsPageFromUnqualified <@> mkNamedAPI @"list-conversation-ids-v2" (conversationIdsPageFromV2 DoNotListGlobalSelf) <@> mkNamedAPI @"list-conversation-ids" conversationIdsPageFrom <@> mkNamedAPI @"get-conversations" getConversations - <@> mkNamedAPI @"list-conversations@v1" listConversations - <@> mkNamedAPI @"list-conversations@v2" listConversations - <@> mkNamedAPI @"list-conversations" listConversations + <@> mkNamedAPI @"list-conversations@v1" (callsFed listConversations) + <@> mkNamedAPI @"list-conversations@v2" (callsFed listConversations) + <@> mkNamedAPI @"list-conversations" (callsFed listConversations) <@> mkNamedAPI @"get-conversation-by-reusable-code" (getConversationByReusableCode @Cassandra) - <@> mkNamedAPI @"create-group-conversation@v2" createGroupConversation - <@> mkNamedAPI @"create-group-conversation" createGroupConversation + <@> mkNamedAPI @"create-group-conversation@v2" (callsFed createGroupConversation) + <@> mkNamedAPI @"create-group-conversation" (callsFed createGroupConversation) <@> mkNamedAPI @"create-self-conversation@v2" createProteusSelfConversation <@> mkNamedAPI @"create-self-conversation" createProteusSelfConversation <@> mkNamedAPI @"get-mls-self-conversation" getMLSSelfConversationWithError - <@> mkNamedAPI @"create-one-to-one-conversation@v2" createOne2OneConversation - <@> mkNamedAPI @"create-one-to-one-conversation" createOne2OneConversation - <@> mkNamedAPI @"add-members-to-conversation-unqualified" addMembersUnqualified - <@> mkNamedAPI @"add-members-to-conversation-unqualified2" addMembersUnqualifiedV2 - <@> mkNamedAPI @"add-members-to-conversation" addMembers - <@> mkNamedAPI @"join-conversation-by-id-unqualified" (joinConversationById @Cassandra) - <@> mkNamedAPI @"join-conversation-by-code-unqualified" (joinConversationByReusableCode @Cassandra) + <@> mkNamedAPI @"create-one-to-one-conversation@v2" (callsFed createOne2OneConversation) + <@> mkNamedAPI @"create-one-to-one-conversation" (callsFed createOne2OneConversation) + <@> mkNamedAPI @"add-members-to-conversation-unqualified" (callsFed addMembersUnqualified) + <@> mkNamedAPI @"add-members-to-conversation-unqualified2" (callsFed addMembersUnqualifiedV2) + <@> mkNamedAPI @"add-members-to-conversation" (callsFed addMembers) + <@> mkNamedAPI @"join-conversation-by-id-unqualified" (callsFed (joinConversationById @Cassandra)) + <@> mkNamedAPI @"join-conversation-by-code-unqualified" (callsFed (joinConversationByReusableCode @Cassandra)) <@> mkNamedAPI @"code-check" (checkReusableCode @Cassandra) <@> mkNamedAPI @"create-conversation-code-unqualified" (addCodeUnqualified @Cassandra) <@> mkNamedAPI @"get-conversation-guest-links-status" (getConversationGuestLinksStatus @Cassandra) <@> mkNamedAPI @"remove-code-unqualified" rmCodeUnqualified <@> mkNamedAPI @"get-code" (getCode @Cassandra) <@> mkNamedAPI @"member-typing-unqualified" isTypingUnqualified - <@> mkNamedAPI @"member-typing-qualified" isTypingQualified - <@> mkNamedAPI @"remove-member-unqualified" removeMemberUnqualified - <@> mkNamedAPI @"remove-member" removeMemberQualified - <@> mkNamedAPI @"update-other-member-unqualified" updateOtherMemberUnqualified - <@> mkNamedAPI @"update-other-member" updateOtherMember - <@> mkNamedAPI @"update-conversation-name-deprecated" updateUnqualifiedConversationName - <@> mkNamedAPI @"update-conversation-name-unqualified" updateUnqualifiedConversationName - <@> mkNamedAPI @"update-conversation-name" updateConversationName - <@> mkNamedAPI @"update-conversation-message-timer-unqualified" updateConversationMessageTimerUnqualified - <@> mkNamedAPI @"update-conversation-message-timer" updateConversationMessageTimer - <@> mkNamedAPI @"update-conversation-receipt-mode-unqualified" updateConversationReceiptModeUnqualified - <@> mkNamedAPI @"update-conversation-receipt-mode" updateConversationReceiptMode - <@> mkNamedAPI @"update-conversation-access-unqualified" updateConversationAccessUnqualified - <@> mkNamedAPI @"update-conversation-access@v2" updateConversationAccess - <@> mkNamedAPI @"update-conversation-access" updateConversationAccess + <@> mkNamedAPI @"member-typing-qualified" (callsFed isTypingQualified) + <@> mkNamedAPI @"remove-member-unqualified" (callsFed removeMemberUnqualified) + <@> mkNamedAPI @"remove-member" (callsFed removeMemberQualified) + <@> mkNamedAPI @"update-other-member-unqualified" (callsFed updateOtherMemberUnqualified) + <@> mkNamedAPI @"update-other-member" (callsFed updateOtherMember) + <@> mkNamedAPI @"update-conversation-name-deprecated" (callsFed updateUnqualifiedConversationName) + <@> mkNamedAPI @"update-conversation-name-unqualified" (callsFed updateUnqualifiedConversationName) + <@> mkNamedAPI @"update-conversation-name" (callsFed updateConversationName) + <@> mkNamedAPI @"update-conversation-message-timer-unqualified" (callsFed updateConversationMessageTimerUnqualified) + <@> mkNamedAPI @"update-conversation-message-timer" (callsFed updateConversationMessageTimer) + <@> mkNamedAPI @"update-conversation-receipt-mode-unqualified" (callsFed updateConversationReceiptModeUnqualified) + <@> mkNamedAPI @"update-conversation-receipt-mode" (callsFed updateConversationReceiptMode) + <@> mkNamedAPI @"update-conversation-access-unqualified" (callsFed updateConversationAccessUnqualified) + <@> mkNamedAPI @"update-conversation-access@v2" (callsFed updateConversationAccess) + <@> mkNamedAPI @"update-conversation-access" (callsFed updateConversationAccess) <@> mkNamedAPI @"get-conversation-self-unqualified" getLocalSelf <@> mkNamedAPI @"update-conversation-self-unqualified" updateUnqualifiedSelfMember <@> mkNamedAPI @"update-conversation-self" updateSelfMember diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 5f2442aee5..4dbc810de6 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -27,16 +27,11 @@ import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Feature import Wire.API.Team.Feature -featureAPI :: - ( CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-new-remote-conversation" - ) => - API FeatureAPI GalleyEffects +featureAPI :: API FeatureAPI GalleyEffects featureAPI = mkNamedAPI @'("get", SSOConfig) (getFeatureStatus @Cassandra . DoAuth) <@> mkNamedAPI @'("get", LegalholdConfig) (getFeatureStatus @Cassandra . DoAuth) - <@> mkNamedAPI @'("put", LegalholdConfig) (setFeatureStatus @Cassandra . DoAuth) + <@> mkNamedAPI @'("put", LegalholdConfig) (callsFed (setFeatureStatus @Cassandra . DoAuth)) <@> mkNamedAPI @'("get", SearchVisibilityAvailableConfig) (getFeatureStatus @Cassandra . DoAuth) <@> mkNamedAPI @'("put", SearchVisibilityAvailableConfig) (setFeatureStatus @Cassandra . DoAuth) <@> mkNamedAPI @'("get-deprecated", SearchVisibilityAvailableConfig) (getFeatureStatus @Cassandra . DoAuth) diff --git a/services/galley/src/Galley/API/Public/LegalHold.hs b/services/galley/src/Galley/API/Public/LegalHold.hs index d0bfc6c41a..405d3ca61a 100644 --- a/services/galley/src/Galley/API/Public/LegalHold.hs +++ b/services/galley/src/Galley/API/Public/LegalHold.hs @@ -24,18 +24,13 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.LegalHold -legalHoldAPI :: - ( CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-new-remote-conversation" - ) => - API LegalHoldAPI GalleyEffects +legalHoldAPI :: API LegalHoldAPI GalleyEffects legalHoldAPI = mkNamedAPI @"create-legal-hold-settings" (createSettings @Cassandra) <@> mkNamedAPI @"get-legal-hold-settings" (getSettings @Cassandra) - <@> mkNamedAPI @"delete-legal-hold-settings" (removeSettingsInternalPaging @Cassandra) + <@> mkNamedAPI @"delete-legal-hold-settings" (callsFed (callsFed (callsFed (removeSettingsInternalPaging @Cassandra)))) <@> mkNamedAPI @"get-legal-hold" getUserStatus - <@> mkNamedAPI @"consent-to-legal-hold" grantConsent - <@> mkNamedAPI @"request-legal-hold-device" (requestDevice @Cassandra) - <@> mkNamedAPI @"disable-legal-hold-for-user" disableForUser - <@> mkNamedAPI @"approve-legal-hold-device" (approveDevice @Cassandra) + <@> mkNamedAPI @"consent-to-legal-hold" (callsFed (callsFed (callsFed grantConsent))) + <@> mkNamedAPI @"request-legal-hold-device" (callsFed (callsFed (callsFed (requestDevice @Cassandra)))) + <@> mkNamedAPI @"disable-legal-hold-for-user" (callsFed (callsFed (callsFed disableForUser))) + <@> mkNamedAPI @"approve-legal-hold-device" (callsFed (callsFed (callsFed (approveDevice @Cassandra)))) diff --git a/services/galley/src/Galley/API/Public/MLS.hs b/services/galley/src/Galley/API/Public/MLS.hs index 43261f9d0c..7581908ccf 100644 --- a/services/galley/src/Galley/API/Public/MLS.hs +++ b/services/galley/src/Galley/API/Public/MLS.hs @@ -23,19 +23,10 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.MLS -mlsAPI :: - ( CallsFed 'Galley "mls-welcome", - CallsFed 'Brig "get-mls-clients", - CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-new-remote-conversation", - CallsFed 'Galley "send-mls-message", - CallsFed 'Galley "send-mls-commit-bundle" - ) => - API MLSAPI GalleyEffects +mlsAPI :: API MLSAPI GalleyEffects mlsAPI = - mkNamedAPI @"mls-welcome-message" postMLSWelcomeFromLocalUser - <@> mkNamedAPI @"mls-message-v1" postMLSMessageFromLocalUserV1 - <@> mkNamedAPI @"mls-message" postMLSMessageFromLocalUser - <@> mkNamedAPI @"mls-commit-bundle" postMLSCommitBundleFromLocalUser + mkNamedAPI @"mls-welcome-message" (callsFed postMLSWelcomeFromLocalUser) + <@> mkNamedAPI @"mls-message-v1" (callsFed postMLSMessageFromLocalUserV1) + <@> mkNamedAPI @"mls-message" (callsFed postMLSMessageFromLocalUser) + <@> mkNamedAPI @"mls-commit-bundle" (callsFed postMLSCommitBundleFromLocalUser) <@> mkNamedAPI @"mls-public-keys" getMLSPublicKeys diff --git a/services/galley/src/Galley/API/Public/Messaging.hs b/services/galley/src/Galley/API/Public/Messaging.hs index 29125fb011..ae5a3248d9 100644 --- a/services/galley/src/Galley/API/Public/Messaging.hs +++ b/services/galley/src/Galley/API/Public/Messaging.hs @@ -23,14 +23,9 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Messaging -messagingAPI :: - ( CallsFed 'Brig "get-user-clients", - CallsFed 'Galley "on-message-sent", - CallsFed 'Galley "send-message" - ) => - API MessagingAPI GalleyEffects +messagingAPI :: API MessagingAPI GalleyEffects messagingAPI = - mkNamedAPI @"post-otr-message-unqualified" postOtrMessageUnqualified + mkNamedAPI @"post-otr-message-unqualified" (callsFed postOtrMessageUnqualified) <@> mkNamedAPI @"post-otr-broadcast-unqualified" postOtrBroadcastUnqualified - <@> mkNamedAPI @"post-proteus-message" postProteusMessage + <@> mkNamedAPI @"post-proteus-message" (callsFed postProteusMessage) <@> mkNamedAPI @"post-proteus-broadcast" postProteusBroadcast diff --git a/services/galley/src/Galley/API/Public/Servant.hs b/services/galley/src/Galley/API/Public/Servant.hs index 974063e151..e7eae6adde 100644 --- a/services/galley/src/Galley/API/Public/Servant.hs +++ b/services/galley/src/Galley/API/Public/Servant.hs @@ -28,29 +28,10 @@ import Galley.API.Public.Team import Galley.API.Public.TeamConversation import Galley.API.Public.TeamMember import Galley.App -import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley -servantSitemap :: - ( CallsFed 'Galley "get-conversations", - CallsFed 'Galley "leave-conversation", - CallsFed 'Galley "on-conversation-created", - CallsFed 'Galley "on-conversation-updated", - CallsFed 'Brig "get-user-clients", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-message-sent", - CallsFed 'Galley "on-new-remote-conversation", - CallsFed 'Galley "on-typing-indicator-updated", - CallsFed 'Galley "send-message", - CallsFed 'Brig "get-mls-clients", - CallsFed 'Galley "query-group-info", - CallsFed 'Galley "mls-welcome", - CallsFed 'Galley "update-conversation", - CallsFed 'Galley "send-mls-commit-bundle", - CallsFed 'Galley "send-mls-message" - ) => - API ServantAPI GalleyEffects +servantSitemap :: API ServantAPI GalleyEffects servantSitemap = conversationAPI <@> teamConversationAPI diff --git a/services/galley/src/Galley/API/Public/TeamConversation.hs b/services/galley/src/Galley/API/Public/TeamConversation.hs index 91abb7c0ad..6aad651f3b 100644 --- a/services/galley/src/Galley/API/Public/TeamConversation.hs +++ b/services/galley/src/Galley/API/Public/TeamConversation.hs @@ -23,14 +23,9 @@ import Wire.API.Federation.API import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamConversation -teamConversationAPI :: - ( CallsFed 'Galley "on-conversation-updated", - CallsFed 'Galley "on-mls-message-sent", - CallsFed 'Galley "on-new-remote-conversation" - ) => - API TeamConversationAPI GalleyEffects +teamConversationAPI :: API TeamConversationAPI GalleyEffects teamConversationAPI = mkNamedAPI @"get-team-conversation-roles" getTeamConversationRoles <@> mkNamedAPI @"get-team-conversations" getTeamConversations <@> mkNamedAPI @"get-team-conversation" getTeamConversation - <@> mkNamedAPI @"delete-team-conversation" deleteTeamConversation + <@> mkNamedAPI @"delete-team-conversation" (callsFed deleteTeamConversation) diff --git a/services/galley/test/integration/API.hs b/services/galley/test/integration/API.hs index 1f37559070..f193cbfee6 100644 --- a/services/galley/test/integration/API.hs +++ b/services/galley/test/integration/API.hs @@ -1141,8 +1141,10 @@ postMessageQualifiedRemoteOwningBackendFailure = do let brigApi _ = mkHandler @(FedApi 'Brig) EmptyAPI let galleyApi _ = mkHandler @(FedApi 'Galley) $ - Named @"send-message" $ \_ _ -> - throwError err503 {errBody = "Down for maintenance."} + Named @"send-message" $ + callsFed $ + callsFed $ \_ _ -> + throwError err503 {errBody = "Down for maintenance."} (resp2, _requests) <- postProteusMessageQualifiedWithMockFederator aliceUnqualified aliceClient convId [] "data" Message.MismatchReportAll brigApi galleyApi @@ -1181,8 +1183,10 @@ postMessageQualifiedRemoteOwningBackendSuccess = do message = [(bobOwningDomain, bobClient, "text-for-bob"), (deeRemote, deeClient, "text-for-dee")] brigApi _ = mkHandler @(FedApi 'Brig) EmptyAPI galleyApi _ = mkHandler @(FedApi 'Galley) $ - Named @"send-message" $ \_ _ -> - pure (F.MessageSendResponse (Right mss)) + Named @"send-message" $ + callsFed $ + callsFed $ \_ _ -> + pure (F.MessageSendResponse (Right mss)) (resp2, _requests) <- postProteusMessageQualifiedWithMockFederator aliceUnqualified aliceClient convId message "data" Message.MismatchReportAll brigApi galleyApi diff --git a/services/galley/test/integration/API/Federation/Util.hs b/services/galley/test/integration/API/Federation/Util.hs index 6bdd39e2f7..727a97c4f2 100644 --- a/services/galley/test/integration/API/Federation/Util.hs +++ b/services/galley/test/integration/API/Federation/Util.hs @@ -23,6 +23,7 @@ import GHC.TypeLits import Imports import Servant import Wire.API.Federation.Domain +import Wire.API.MakesFederatedCall import Wire.API.Routes.Named import Wire.API.VersionInfo @@ -38,6 +39,9 @@ instance HasTrivialHandler api => HasTrivialHandler ((path :: Symbol) :> api) wh instance HasTrivialHandler api => HasTrivialHandler (OriginDomainHeader :> api) where trivialHandler name _ = trivialHandler @api name +instance HasTrivialHandler api => HasTrivialHandler (MakesFederatedCall comp name :> api) where + trivialHandler name _ = trivialHandler @api name + instance HasTrivialHandler api => HasTrivialHandler (ReqBody cs a :> api) where trivialHandler name _ = trivialHandler @api name diff --git a/services/galley/test/integration/TestSetup.hs b/services/galley/test/integration/TestSetup.hs index e01fc52b14..9714e98fc4 100644 --- a/services/galley/test/integration/TestSetup.hs +++ b/services/galley/test/integration/TestSetup.hs @@ -130,7 +130,7 @@ instance MonadHttp TestM where runFedClient :: forall (name :: Symbol) comp m api. - ( HasFedEndpoint comp api name, + ( HasUnsafeFedEndpoint comp api name, Servant.HasClient Servant.ClientM api, MonadIO m ) => From dd6f4f27887ebdd05d725270e9a513223e9f14b0 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 5 Jan 2023 11:29:23 +0100 Subject: [PATCH 09/33] [SQSERVICES-1824] [fix] Entering 2FA code multiple times does not invalidate it (#2960) --- changelog.d/3-bug-fixes/pr-2960 | 1 + services/brig/src/Brig/Code.hs | 2 +- .../brig/test/integration/API/User/Auth.hs | 80 ++++++++++++------- 3 files changed, 52 insertions(+), 31 deletions(-) create mode 100644 changelog.d/3-bug-fixes/pr-2960 diff --git a/changelog.d/3-bug-fixes/pr-2960 b/changelog.d/3-bug-fixes/pr-2960 new file mode 100644 index 0000000000..47c97848ed --- /dev/null +++ b/changelog.d/3-bug-fixes/pr-2960 @@ -0,0 +1 @@ +Limit 2FA code retries to 3 attempts diff --git a/services/brig/src/Brig/Code.hs b/services/brig/src/Brig/Code.hs index 695ba1952c..42ca782a80 100644 --- a/services/brig/src/Brig/Code.hs +++ b/services/brig/src/Brig/Code.hs @@ -332,7 +332,7 @@ verify :: MonadClient m => Key -> Scope -> Value -> m (Maybe Code) verify k s v = lookup k s >>= maybe (pure Nothing) continue where continue c - | codeValue c == v = pure (Just c) + | codeValue c == v && codeRetries c > 0 = pure (Just c) | codeRetries c > 0 = do insertInternal (c {codeRetries = codeRetries c - 1}) pure Nothing diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index 49ec997223..834bd46a69 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -36,6 +36,7 @@ import Brig.ZAuth (ZAuth, runZAuth) import qualified Brig.ZAuth as ZAuth import qualified Cassandra as DB import Control.Lens (set, (^.)) +import Control.Monad.Catch (MonadCatch) import Control.Retry import Data.Aeson as Aeson hiding (json) import qualified Data.ByteString as BS @@ -134,7 +135,8 @@ tests conf m z db b g n = test m "test-login-verify6-digit-wrong-code-fails" $ testLoginVerify6DigitWrongCodeFails b g, test m "test-login-verify6-digit-missing-code-fails" $ testLoginVerify6DigitMissingCodeFails b g, test m "test-login-verify6-digit-expired-code-fails" $ testLoginVerify6DigitExpiredCodeFails b g db, - test m "test-login-verify6-digit-resend-code-success-and-rate-limiting" $ testLoginVerify6DigitResendCodeSuccessAndRateLimiting b g conf db + test m "test-login-verify6-digit-resend-code-success-and-rate-limiting" $ testLoginVerify6DigitResendCodeSuccessAndRateLimiting b g conf db, + test m "test-login-verify6-digit-limit-retries" $ testLoginVerify6DigitLimitRetries b g conf db ] ], testGroup @@ -420,10 +422,6 @@ testLoginVerify6DigitResendCodeSuccessAndRateLimiting brig galley _opts db = do (u, tid) <- createUserWithTeam' brig let Just email = userEmail u let checkLoginSucceeds body = login brig body PersistentCookie !!! const 200 === statusCode - let checkLoginFails body = - login brig body PersistentCookie !!! do - const 403 === statusCode - const (Just "code-authentication-failed") === errorLabel let getCodeFromDb = do key <- Code.mkKey (Code.ForEmail email) Just c <- Util.lookupCode db key Code.AccountLogin @@ -441,7 +439,7 @@ testLoginVerify6DigitResendCodeSuccessAndRateLimiting brig galley _opts db = do void $ retryWhileN 10 ((==) 429 . statusCode) $ Util.generateVerificationCode' brig (Public.SendVerificationCode Public.Login email) mostRecentCode <- getCodeFromDb - checkLoginFails $ + checkLoginFails brig $ PasswordLogin $ PasswordLoginData (LoginByEmail email) @@ -456,6 +454,34 @@ testLoginVerify6DigitResendCodeSuccessAndRateLimiting brig galley _opts db = do (Just defCookieLabel) (Just $ Code.codeValue mostRecentCode) +testLoginVerify6DigitLimitRetries :: Brig -> Galley -> Opts.Opts -> DB.ClientState -> Http () +testLoginVerify6DigitLimitRetries brig galley _opts db = do + (u, tid) <- createUserWithTeam' brig + let Just email = userEmail u + Util.setTeamFeatureLockStatus @Public.SndFactorPasswordChallengeConfig galley tid Public.LockStatusUnlocked + Util.setTeamSndFactorPasswordChallenge galley tid Public.FeatureStatusEnabled + Util.generateVerificationCode brig (Public.SendVerificationCode Public.Login email) + key <- Code.mkKey (Code.ForEmail email) + Just correctCode <- Util.lookupCode db key Code.AccountLogin + let wrongCode = Code.Value $ unsafeRange (fromRight undefined (validate "123456")) + -- login with wrong code should fail 3 times + forM_ [1 .. 3] $ \(_ :: Int) -> + checkLoginFails brig $ + PasswordLogin $ + PasswordLoginData + (LoginByEmail email) + defPassword + (Just defCookieLabel) + (Just wrongCode) + -- after 3 failed attempts, login with correct code should fail as well + checkLoginFails brig $ + PasswordLogin $ + PasswordLoginData + (LoginByEmail email) + defPassword + (Just defCookieLabel) + (Just (Code.codeValue correctCode)) + -- @SF.Channel @TSFI.RESTfulAPI @S2 -- -- Test that login fails with wrong second factor email verification code @@ -463,16 +489,11 @@ testLoginVerify6DigitWrongCodeFails :: Brig -> Galley -> Http () testLoginVerify6DigitWrongCodeFails brig galley = do (u, tid) <- createUserWithTeam' brig let Just email = userEmail u - let checkLoginFails body = - login brig body PersistentCookie !!! do - const 403 === statusCode - const (Just "code-authentication-failed") === errorLabel - Util.setTeamFeatureLockStatus @Public.SndFactorPasswordChallengeConfig galley tid Public.LockStatusUnlocked Util.setTeamSndFactorPasswordChallenge galley tid Public.FeatureStatusEnabled Util.generateVerificationCode brig (Public.SendVerificationCode Public.Login email) let wrongCode = Code.Value $ unsafeRange (fromRight undefined (validate "123456")) - checkLoginFails $ + checkLoginFails brig $ PasswordLogin $ PasswordLoginData (LoginByEmail email) @@ -489,21 +510,19 @@ testLoginVerify6DigitMissingCodeFails :: Brig -> Galley -> Http () testLoginVerify6DigitMissingCodeFails brig galley = do (u, tid) <- createUserWithTeam' brig let Just email = userEmail u - let checkLoginFails body = - login brig body PersistentCookie !!! do - const 403 === statusCode - const (Just "code-authentication-required") === errorLabel - Util.setTeamFeatureLockStatus @Public.SndFactorPasswordChallengeConfig galley tid Public.LockStatusUnlocked Util.setTeamSndFactorPasswordChallenge galley tid Public.FeatureStatusEnabled Util.generateVerificationCode brig (Public.SendVerificationCode Public.Login email) - checkLoginFails $ - PasswordLogin $ - PasswordLoginData - (LoginByEmail email) - defPassword - (Just defCookieLabel) - Nothing + let body = + PasswordLogin $ + PasswordLoginData + (LoginByEmail email) + defPassword + (Just defCookieLabel) + Nothing + login brig body PersistentCookie !!! do + const 403 === statusCode + const (Just "code-authentication-required") === errorLabel -- @END @@ -514,11 +533,6 @@ testLoginVerify6DigitExpiredCodeFails :: Brig -> Galley -> DB.ClientState -> Htt testLoginVerify6DigitExpiredCodeFails brig galley db = do (u, tid) <- createUserWithTeam' brig let Just email = userEmail u - let checkLoginFails body = - login brig body PersistentCookie !!! do - const 403 === statusCode - const (Just "code-authentication-failed") === errorLabel - Util.setTeamFeatureLockStatus @Public.SndFactorPasswordChallengeConfig galley tid Public.LockStatusUnlocked Util.setTeamSndFactorPasswordChallenge galley tid Public.FeatureStatusEnabled Util.generateVerificationCode brig (Public.SendVerificationCode Public.Login email) @@ -526,7 +540,7 @@ testLoginVerify6DigitExpiredCodeFails brig galley db = do Just vcode <- Util.lookupCode db key Code.AccountLogin -- wait > 5 sec for the code to expire (assumption: setVerificationTimeout in brig.integration.yaml is set to <= 5 sec) threadDelay $ (5 * 1000000) + 600000 - checkLoginFails $ + checkLoginFails brig $ PasswordLogin $ PasswordLoginData (LoginByEmail email) @@ -1465,3 +1479,9 @@ remJson p l ids = wait :: MonadIO m => m () wait = liftIO $ threadDelay 1000000 + +checkLoginFails :: (MonadHttp m, MonadIO m, MonadCatch m) => Brig -> Login -> m () +checkLoginFails brig body = do + login brig body PersistentCookie !!! do + const 403 === statusCode + const (Just "code-authentication-failed") === errorLabel From 04839fde630fc6ab76e2bca15f93f60f2ecf6488 Mon Sep 17 00:00:00 2001 From: Sebastian Willenborg Date: Thu, 5 Jan 2023 16:34:01 +0100 Subject: [PATCH 10/33] docs: remove invalid scheme from example socks5 host (#2961) * doc: remove invalid scheme from example socks5 host * doc: make example YAML config for deeplink configs a valid YAML file --- docs/src/how-to/associate/deeplink.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/how-to/associate/deeplink.rst b/docs/src/how-to/associate/deeplink.rst index d46d6750a3..1ef53550b1 100644 --- a/docs/src/how-to/associate/deeplink.rst +++ b/docs/src/how-to/associate/deeplink.rst @@ -109,8 +109,8 @@ As of release ``2.117.0`` from ``2021-10-29`` (see `release notes accountsURL: "https://account.example.com" blackListURL: "https://clientblacklist.wire.com/prod" websiteURL: "https://wire.com" - apiProxy: (optional) - host: "https://socks5.proxy.com" + apiProxy: # (optional) + host: "socks5.proxy.com" port: 1080 needsAuthentication: true title: "My Custom Wire Backend" @@ -146,7 +146,7 @@ Otherwise you need to create a ``.json`` file, and host it somewhere users can g "websiteURL" : "https://wire.com" }, "apiProxy" : { - "host" : "https://socks5.proxy.com", + "host" : "socks5.proxy.com", "port" : 1080, "needsAuthentication" : true }, From b83b5840f65c28475559646e1331b8e4e1e4591a Mon Sep 17 00:00:00 2001 From: Paolo Capriotti Date: Fri, 6 Jan 2023 10:24:06 +0100 Subject: [PATCH 11/33] Introduce disabledAPIVersions (#2951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stefan Matting Co-authored-by: Marko Dimjašević --- changelog.d/2-features/pr-2951 | 1 + charts/brig/templates/configmap.yaml | 3 + charts/brig/values.yaml | 3 + charts/cannon/templates/configmap.yaml | 4 + charts/cannon/values.yaml | 4 + charts/cargohold/templates/configmap.yaml | 11 ++- charts/cargohold/values.yaml | 7 ++ charts/galley/templates/configmap.yaml | 3 + charts/galley/values.yaml | 3 + charts/gundeck/templates/configmap.yaml | 4 + charts/gundeck/values.yaml | 4 + charts/proxy/templates/configmap.yaml | 4 +- charts/proxy/values.yaml | 3 + charts/spar/templates/configmap.yaml | 6 +- charts/spar/values.yaml | 3 + docs/src/developer/developer/how-to.md | 7 ++ .../src/developer/reference/config-options.md | 34 +++++++++ hack/helmfile-single.yaml | 2 + .../src/Wire/API/Routes/Version/Wai.hs | 8 +- libs/wire-api/src/Wire/API/User/Saml.hs | 40 +--------- services/brig/src/Brig/Options.hs | 8 +- services/brig/src/Brig/Run.hs | 3 +- services/brig/src/Brig/Version.hs | 14 ++-- services/brig/test/integration/API/Version.hs | 74 ++++++++++++++++++- services/cannon/src/Cannon/Options.hs | 5 +- services/cannon/src/Cannon/Run.hs | 2 +- services/cargohold/src/CargoHold/Options.hs | 4 +- services/cargohold/src/CargoHold/Run.hs | 2 +- services/galley/src/Galley/Options.hs | 5 +- services/galley/src/Galley/Run.hs | 2 +- services/gundeck/src/Gundeck/Options.hs | 4 +- services/gundeck/src/Gundeck/Run.hs | 2 +- services/proxy/src/Proxy/Options.hs | 8 +- services/proxy/src/Proxy/Run.hs | 2 +- services/spar/src/Spar/API.hs | 1 + services/spar/src/Spar/App.hs | 1 + .../spar/src/Spar/CanonicalInterpreter.hs | 1 + services/spar/src/Spar/Data.hs | 1 + services/spar/src/Spar/Options.hs | 52 ++++++++++++- services/spar/src/Spar/Run.hs | 8 +- services/spar/src/Spar/Scim.hs | 2 +- services/spar/src/Spar/Scim/Auth.hs | 2 +- services/spar/src/Spar/Scim/User.hs | 2 +- .../src/Spar/Sem/AReqIDStore/Cassandra.hs | 1 + .../spar/src/Spar/Sem/AssIDStore/Cassandra.hs | 1 + services/spar/src/Spar/Sem/SAML2/Library.hs | 2 +- .../test-integration/Test/Spar/APISpec.hs | 4 +- .../test-integration/Test/Spar/DataSpec.hs | 1 + .../Test/Spar/Scim/UserSpec.hs | 6 +- services/spar/test-integration/Util/Core.hs | 3 +- services/spar/test-integration/Util/Types.hs | 2 +- 51 files changed, 292 insertions(+), 87 deletions(-) create mode 100644 changelog.d/2-features/pr-2951 diff --git a/changelog.d/2-features/pr-2951 b/changelog.d/2-features/pr-2951 new file mode 100644 index 0000000000..7fe0aeddb9 --- /dev/null +++ b/changelog.d/2-features/pr-2951 @@ -0,0 +1 @@ +Introduce optional disabledAPIVersions configuration setting diff --git a/charts/brig/templates/configmap.yaml b/charts/brig/templates/configmap.yaml index df36b2331b..a62139035a 100644 --- a/charts/brig/templates/configmap.yaml +++ b/charts/brig/templates/configmap.yaml @@ -299,5 +299,8 @@ data: {{- if .setEnableMLS }} setEnableMLS: {{ .setEnableMLS }} {{- end }} + {{- if .setDisabledAPIVersions }} + setDisabledAPIVersions: {{ .setDisabledAPIVersions }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/brig/values.yaml b/charts/brig/values.yaml index 99418a4c6f..6f97219f49 100644 --- a/charts/brig/values.yaml +++ b/charts/brig/values.yaml @@ -87,6 +87,9 @@ config: setNonceTtlSecs: 300 # 5 minutes setDpopMaxSkewSecs: 1 setDpopTokenExpirationTimeSecs: 300 # 5 minutes + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # setDisabledAPIVersions: [ 3 ] smtp: passwordFile: /etc/wire/brig/secrets/smtp-password.txt proxy: {} diff --git a/charts/cannon/templates/configmap.yaml b/charts/cannon/templates/configmap.yaml index 256dae79e4..940d601306 100644 --- a/charts/cannon/templates/configmap.yaml +++ b/charts/cannon/templates/configmap.yaml @@ -19,6 +19,10 @@ data: millisecondsBetweenBatches: {{ .Values.config.drainOpts.millisecondsBetweenBatches }} minBatchSize: {{ .Values.config.drainOpts.minBatchSize }} + {{- if .Values.config.disabledAPIVersions }} + disabledAPIVersions: {{ .Values.config.disabledAPIVersions }} + {{- end }} + kind: ConfigMap metadata: name: cannon diff --git a/charts/cannon/values.yaml b/charts/cannon/values.yaml index 41f0c89106..9142603160 100644 --- a/charts/cannon/values.yaml +++ b/charts/cannon/values.yaml @@ -22,6 +22,10 @@ config: millisecondsBetweenBatches: 50 minBatchSize: 20 + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] + metrics: serviceMonitor: enabled: false diff --git a/charts/cargohold/templates/configmap.yaml b/charts/cargohold/templates/configmap.yaml index 942185bda3..5f6cd7cbc4 100644 --- a/charts/cargohold/templates/configmap.yaml +++ b/charts/cargohold/templates/configmap.yaml @@ -41,7 +41,14 @@ data: settings: {{- with .Values.config.settings }} - maxTotalBytes: 5368709120 - downloadLinkTTL: 300 # Seconds + {{- if .maxTotalBytes }} + maxTotalBytes: {{ .maxTotalBytes }} + {{- end }} + {{- if .downloadLinkTTL }} + downloadLinkTTL: {{ .downloadLinkTTL }} + {{- end }} federationDomain: {{ .federationDomain }} + {{- if .disabledAPIVersions }} + disabledAPIVersions: {{ .disabledAPIVersions }} + {{- end }} {{- end }} diff --git a/charts/cargohold/values.yaml b/charts/cargohold/values.yaml index 76a59b0811..5445d1bc23 100644 --- a/charts/cargohold/values.yaml +++ b/charts/cargohold/values.yaml @@ -23,6 +23,13 @@ config: region: "eu-west-1" s3Bucket: assets proxy: {} + settings: + maxTotalBytes: 5368709120 + downloadLinkTTL: 300 # Seconds + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] + serviceAccount: # When setting this to 'false', either make sure that a service account named # 'cargohold' exists or change the 'name' field to 'default' diff --git a/charts/galley/templates/configmap.yaml b/charts/galley/templates/configmap.yaml index e5a4f7864a..a761fb24fd 100644 --- a/charts/galley/templates/configmap.yaml +++ b/charts/galley/templates/configmap.yaml @@ -69,6 +69,9 @@ data: ed25519: "/etc/wire/galley/secrets/removal_ed25519.pem" {{- end }} {{- end -}} + {{- if .settings.disabledAPIVersions }} + disabledAPIVersions: {{ .settings.disabledAPIVersions }} + {{- end }} {{- if .settings.featureFlags }} featureFlags: sso: {{ .settings.featureFlags.sso }} diff --git a/charts/galley/values.yaml b/charts/galley/values.yaml index 7e20021638..8f260a0abe 100644 --- a/charts/galley/values.yaml +++ b/charts/galley/values.yaml @@ -32,6 +32,9 @@ config: # Before making indexedBillingTeamMember true while upgrading, please # refer to notes here: https://github.com/wireapp/wire-server-deploy/releases/tag/v2020-05-15 indexedBillingTeamMember: false + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] featureFlags: # see #RefConfigOptions in `/docs/reference` (https://github.com/wireapp/wire-server/) appLock: defaults: diff --git a/charts/gundeck/templates/configmap.yaml b/charts/gundeck/templates/configmap.yaml index d2b9a18ccc..2349e68cc4 100644 --- a/charts/gundeck/templates/configmap.yaml +++ b/charts/gundeck/templates/configmap.yaml @@ -53,6 +53,10 @@ data: {{- if hasKey . "perNativePushConcurrency" }} perNativePushConcurrency: {{ .perNativePushConcurrency }} {{- end }} + {{- if .disabledAPIVersions }} + disabledAPIVersions: {{ .disabledAPIVersions }} + {{- end }} + # disabledAPIVersions: [ 2 ] maxConcurrentNativePushes: soft: {{ .maxConcurrentNativePushes.soft }} {{- if hasKey .maxConcurrentNativePushes "hard" }} diff --git a/charts/gundeck/values.yaml b/charts/gundeck/values.yaml index 83ed95df1a..3f8a547229 100644 --- a/charts/gundeck/values.yaml +++ b/charts/gundeck/values.yaml @@ -35,6 +35,10 @@ config: # perNativePushConcurrency: 32 maxConcurrentNativePushes: soft: 1000 + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] + serviceAccount: # When setting this to 'false', either make sure that a service account named # 'gundeck' exists or change the 'name' field to 'default' diff --git a/charts/proxy/templates/configmap.yaml b/charts/proxy/templates/configmap.yaml index 5af2ebe10c..5464879752 100644 --- a/charts/proxy/templates/configmap.yaml +++ b/charts/proxy/templates/configmap.yaml @@ -7,7 +7,9 @@ data: logFormat: {{ .Values.config.logFormat }} logLevel: {{ .Values.config.logLevel }} logNetStrings: {{ .Values.config.logNetStrings }} - + {{- if .Values.config.disabledAPIVersions }} + disabledAPIVersions: {{ .Values.config.disabledAPIVersions }} + {{- end }} host: 0.0.0.0 port: {{ .Values.service.internalPort }} httpPoolSize: 1000 diff --git a/charts/proxy/values.yaml b/charts/proxy/values.yaml index 2e527e91db..6dd53032a9 100644 --- a/charts/proxy/values.yaml +++ b/charts/proxy/values.yaml @@ -19,3 +19,6 @@ config: logFormat: StructuredJSON logNetStrings: false proxy: {} + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] diff --git a/charts/spar/templates/configmap.yaml b/charts/spar/templates/configmap.yaml index 2a195f7487..98711a4679 100644 --- a/charts/spar/templates/configmap.yaml +++ b/charts/spar/templates/configmap.yaml @@ -33,6 +33,10 @@ data: maxScimTokens: {{ .maxScimTokens }} + {{- if .disabledAPIVersions }} + disabledAPIVersions: {{ .disabledAPIVersions }} + {{- end }} + saml: version: SAML2.0 logLevel: {{ .logLevel }} @@ -43,5 +47,5 @@ data: spSsoUri: {{ .ssoUri }} contacts: -{{ toYaml .contacts | indent 12 }} + {{- toYaml .contacts | nindent 8 }} {{- end }} diff --git a/charts/spar/values.yaml b/charts/spar/values.yaml index f378ebdc96..c2023b6634 100644 --- a/charts/spar/values.yaml +++ b/charts/spar/values.yaml @@ -25,3 +25,6 @@ config: maxttlAuthreq: 7200 maxttlAuthresp: 7200 proxy: {} + # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: + # brig, cannon, cargohold, galley, gundeck, proxy, spar. + # disabledAPIVersions: [ 3 ] diff --git a/docs/src/developer/developer/how-to.md b/docs/src/developer/developer/how-to.md index 0ed606399b..14c0e278d9 100644 --- a/docs/src/developer/developer/how-to.md +++ b/docs/src/developer/developer/how-to.md @@ -2,6 +2,13 @@ The following assume you have a working developer environment with all the dependencies listed in [./dependencies.md](./dependencies.md) available to you. +If you want to deploy to the CI kubernetes cluster (how-tos below), you need to set the `KUBECONFIG` env var, where `$cailleach_repo` is replaced by your local checkout of the `cailleach` repository. +``` +export KUBECONFIG=$cailleach_repo/environments/kube-ci/kubeconfig.dec +``` +Check that this file exists by running `ls $KUBECONFIG`. + + ## How to look at the swagger docs / UI locally Terminal 1: diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 4fe558e4ad..98eb07ff17 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -604,3 +604,37 @@ If there is no configuration for a domain, it's defaulted to `no_search`. #### `setEnableDevelopmentVersions` This options determines whether development versions should be enabled. If set to `False`, all development versions are removed from the `supported` field of the `/api-version` endpoint. Note that they are still listed in the `development` field, and continue to work normally. + +#### Disabling API versions + +It is possible to disable one ore more API versions. When an API version is disabled it won't be advertised on the `GET /api-version` endpoint, neither in the `supported`, nor in the `development` section. Requests made to any endpoint of a disabled API version will result in the same error response as a request made to an API version that does not exist. + +Each of the services brig, cannon, cargohold, galley, gundeck, proxy, spar should to be configured with the same set of disable API versions in each service's values.yaml config files. + + +For example to disable API version v3, you need to configure: + +``` +# brig's values.yaml +config.optSettings.setDisabledAPIVersions: [ 3 ] + +# cannon's values.yaml +config.disabledAPIVersions: [ 3 ] + +# cargohold's values.yaml +config.settings.disabledAPIVersions: [ 3 ] + +# galley's values.yaml +config.settings.disabledAPIVersions: [ 3 ] + +# gundecks' values.yaml +config.disabledAPIVersions: [ 3 ] + +# proxy's values.yaml +config.disabledAPIVersions: [ 3 ] + +# spar's values.yaml +config.disabledAPIVersions: [ 3 ] +``` + +The default setting is that no API version is disabled. diff --git a/hack/helmfile-single.yaml b/hack/helmfile-single.yaml index 3d75ce0d8b..790412bf71 100644 --- a/hack/helmfile-single.yaml +++ b/hack/helmfile-single.yaml @@ -73,3 +73,5 @@ releases: value: {{ .Values.federationDomain }} - name: galley.config.settings.federationDomain value: {{ .Values.federationDomain }} + - name: cargohold.config.settings.federationDomain + value: {{ .Values.federationDomain }} diff --git a/libs/wire-api/src/Wire/API/Routes/Version/Wai.hs b/libs/wire-api/src/Wire/API/Routes/Version/Wai.hs index 25a4add2bc..545acdeae4 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version/Wai.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version/Wai.hs @@ -28,12 +28,12 @@ import Network.Wai.Utilities.Response import Wire.API.Routes.Version -- | Strip off version prefix. Return 404 if the version is not supported. -versionMiddleware :: Middleware -versionMiddleware app req k = case parseVersion (removeVersionHeader req) of +versionMiddleware :: Set Version -> Middleware +versionMiddleware disabledAPIVersions app req k = case parseVersion (removeVersionHeader req) of Nothing -> app req k Just (req', n) -> case mkVersion n of - Just v -> app (addVersionHeader v req') k - Nothing -> + Just v | v `notElem` disabledAPIVersions -> app (addVersionHeader v req') k + _ -> k $ errorRs' $ mkError HTTP.status404 "unsupported-version" $ diff --git a/libs/wire-api/src/Wire/API/User/Saml.hs b/libs/wire-api/src/Wire/API/User/Saml.hs index 4d3939a3f4..eebe6a1f65 100644 --- a/libs/wire-api/src/Wire/API/User/Saml.hs +++ b/libs/wire-api/src/Wire/API/User/Saml.hs @@ -38,13 +38,9 @@ import Data.Time import GHC.TypeLits (KnownSymbol, symbolVal) import GHC.Types (Symbol) import Imports -import SAML2.Util (parseURI', renderURI) -import SAML2.WebSSO (Assertion, AuthnRequest, ID, IdPId) -import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO import SAML2.WebSSO.Types.TH (deriveJSONOptions) -import System.Logger.Extended (LogFormat) import URI.ByteString -import Util.Options import Web.Cookie import Wire.API.User.Orphans () @@ -87,37 +83,6 @@ substituteVar var val = substituteVar' ("$" <> var) val . substituteVar' ("%24" substituteVar' :: ST -> ST -> ST -> ST substituteVar' var val = ST.intercalate val . ST.splitOn var -type Opts = Opts' DerivedOpts - --- FUTUREWORK: Shouldn't these types be in spar, not in wire-api? -data Opts' a = Opts - { saml :: !SAML.Config, - brig :: !Endpoint, - galley :: !Endpoint, - cassandra :: !CassandraOpts, - maxttlAuthreq :: !(TTL "authreq"), - maxttlAuthresp :: !(TTL "authresp"), - -- | The maximum number of SCIM tokens that we will allow teams to have. - maxScimTokens :: !Int, - -- | The maximum size of rich info. Should be in sync with 'Brig.Types.richInfoLimit'. - richInfoLimit :: !Int, - -- | Wire/AWS specific; optional; used to discover Cassandra instance - -- IPs using describe-instances. - discoUrl :: !(Maybe Text), - logNetStrings :: !(Maybe (Last Bool)), - logFormat :: !(Maybe (Last LogFormat)), - -- , optSettings :: !Settings -- (nothing yet; see other services for what belongs in here.) - derivedOpts :: !a - } - deriving (Functor, Show, Generic) - -instance FromJSON (Opts' (Maybe ())) - -data DerivedOpts = DerivedOpts - { derivedOptsScimBaseURI :: !URI - } - deriving (Show, Generic) - -- | (seconds) newtype TTL (tablename :: Symbol) = TTL {fromTTL :: Int32} deriving (Eq, Ord, Show, Num) @@ -134,9 +99,6 @@ data TTLError = TTLTooLong String String | TTLNegative String ttlToNominalDiffTime :: TTL a -> NominalDiffTime ttlToNominalDiffTime (TTL i32) = fromIntegral i32 -maxttlAuthreqDiffTime :: Opts -> NominalDiffTime -maxttlAuthreqDiffTime = ttlToNominalDiffTime . maxttlAuthreq - data SsoSettings = SsoSettings { defaultSsoCode :: !(Maybe IdPId) } diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index 464dc3d62f..941a683aba 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -52,6 +52,7 @@ import Imports import qualified Network.DNS as DNS import System.Logger.Extended (Level, LogFormat) import Util.Options +import Wire.API.Routes.Version import qualified Wire.API.Team.Feature as Public import Wire.API.User import Wire.API.User.Search (FederatedUserSearchPolicy) @@ -587,8 +588,10 @@ data Settings = Settings setSftListAllServers :: Maybe ListAllSFTServers, setEnableMLS :: Maybe Bool, setKeyPackageMaximumLifetime :: Maybe NominalDiffTime, - -- | When set, development API versions are advertised to clients. + -- | When set, development API versions are advertised to clients as supported. setEnableDevelopmentVersions :: Maybe Bool, + -- | Disabled versions are not advertised and are completely disabled. + setDisabledAPIVersions :: Maybe (Set Version), -- | Minimum delay in seconds between consecutive attempts to generate a new verification code. -- use `set2FACodeGenerationDelaySecs` as the getter function which always provides a default value set2FACodeGenerationDelaySecsInternal :: !(Maybe Int), @@ -859,7 +862,8 @@ Lens.makeLensesFor ("setFederationDomainConfigs", "federationDomainConfigs"), ("setEnableDevelopmentVersions", "enableDevelopmentVersions"), ("setRestrictUserCreation", "restrictUserCreation"), - ("setEnableMLS", "enableMLS") + ("setEnableMLS", "enableMLS"), + ("setDisabledAPIVersions", "disabledAPIVersions") ] ''Settings diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index bf440e2948..4e600477df 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -126,7 +126,8 @@ mkApp o = do middleware :: Env -> (RequestId -> Wai.Application) -> Wai.Application middleware e = - versionMiddleware -- this rewrites the request, so it must be at the top (i.e. applied last) + -- this rewrites the request, so it must be at the top (i.e. applied last) + versionMiddleware (fold (setDisabledAPIVersions (optSettings o))) . Metrics.servantPlusWAIPrometheusMiddleware (sitemap @BrigCanonicalEffects) (Proxy @ServantCombinedAPI) . GZip.gunzip . GZip.gzip GZip.def diff --git a/services/brig/src/Brig/Version.hs b/services/brig/src/Brig/Version.hs index 73f7f1fd74..acf7603d76 100644 --- a/services/brig/src/Brig/Version.hs +++ b/services/brig/src/Brig/Version.hs @@ -21,6 +21,7 @@ import Brig.API.Handler import Brig.App import Brig.Options import Control.Lens +import qualified Data.Set as Set import Imports import Servant (ServerT) import Wire.API.Routes.Named @@ -31,13 +32,16 @@ versionAPI = Named $ do fed <- view federator dom <- viewFederationDomain dev <- view (settings . enableDevelopmentVersions . to (fromMaybe False)) - let supported - | dev = supportedVersions - | otherwise = supportedVersions \\ developmentVersions + disabledVersions <- view (settings . disabledAPIVersions . traverse) + let allVersions = Set.difference (Set.fromList supportedVersions) disabledVersions + devVersions = Set.difference (Set.fromList developmentVersions) disabledVersions + supported + | dev = allVersions + | otherwise = Set.difference allVersions devVersions pure $ VersionInfo - { vinfoSupported = supported, - vinfoDevelopment = developmentVersions, + { vinfoSupported = toList supported, + vinfoDevelopment = toList devVersions, vinfoFederation = isJust fed, vinfoDomain = dom } diff --git a/services/brig/test/integration/API/Version.hs b/services/brig/test/integration/API/Version.hs index 4995d8754e..bd4945e879 100644 --- a/services/brig/test/integration/API/Version.hs +++ b/services/brig/test/integration/API/Version.hs @@ -20,13 +20,17 @@ module API.Version (tests) where import Bilge import Bilge.Assert import Brig.Options +import qualified Brig.Options as Opt import Control.Lens ((?~)) +import Control.Monad.Catch (MonadCatch) +import qualified Data.Set as Set import Imports import qualified Network.Wai.Utilities.Error as Wai import Test.Tasty import Test.Tasty.HUnit import Util import Wire.API.Routes.Version +import Wire.API.User tests :: Manager -> Opts -> Brig -> TestTree tests p opts brig = @@ -36,7 +40,10 @@ tests p opts brig = test p "GET /v1/api-version" $ testVersionV1 brig, test p "GET /api-version (with dev)" $ testDevVersion opts brig, test p "GET /v500/api-version" $ testUnsupportedVersion brig, - test p "GET /api-version (federation info)" $ testFederationDomain opts brig + test p "GET /api-version (federation info)" $ testFederationDomain opts brig, + test p "Disabled version is unsupported" $ testDisabledVersionIsUnsupported opts brig, + test p "Disabled version is not advertised" $ testVersionDisabledSupportedVersion opts brig, + test p "Disabled dev version is not advertised" $ testVersionDisabledDevelopmentVersion opts brig ] testVersion :: Brig -> Http () @@ -86,3 +93,68 @@ testFederationDomain opts brig = do liftIO $ do vinfoFederation vinfo @?= True vinfoDomain vinfo @?= domain + +testDisabledVersionIsUnsupported :: Opts -> Brig -> Http () +testDisabledVersionIsUnsupported opts brig = do + uid <- userId <$> randomUser brig + + get (apiVersion "v2" . brig . path "/self" . zUser uid) + !!! const 200 === statusCode + + withSettingsOverrides + ( opts + & Opt.optionSettings + . Opt.disabledAPIVersions + ?~ Set.fromList [V2] + ) + $ do + err <- + responseJsonError + =<< get (apiVersion "v2" . brig . path "/self" . zUser uid) + Brig -> Http () +testVersionDisabledSupportedVersion opts brig = do + vinfo <- getVersionInfo brig + liftIO $ filter (== V2) (vinfoSupported vinfo) @?= [V2] + disabledVersionIsNotAdvertised opts brig V2 + +testVersionDisabledDevelopmentVersion :: Opts -> Brig -> Http () +testVersionDisabledDevelopmentVersion opts brig = do + vinfo <- getVersionInfo brig + for_ (listToMaybe (vinfoDevelopment vinfo)) $ \devVersion -> do + liftIO $ filter (== devVersion) (vinfoDevelopment vinfo) @?= [devVersion] + disabledVersionIsNotAdvertised opts brig devVersion + +disabledVersionIsNotAdvertised :: Opts -> Brig -> Version -> Http () +disabledVersionIsNotAdvertised opts brig version = + withSettingsOverrides + ( opts + & Opt.optionSettings + . Opt.disabledAPIVersions + ?~ Set.fromList [version] + ) + $ do + vinfo <- getVersionInfo brig + liftIO $ filter (== version) (vinfoSupported vinfo) @?= [] + liftIO $ filter (== version) (vinfoDevelopment vinfo) @?= [] + +getVersionInfo :: + (MonadIO m, MonadCatch m, MonadFail m, MonadHttp m, HasCallStack) => + Brig -> + m VersionInfo +getVersionInfo brig = + responseJsonError + =<< get (unversioned . brig . path "/api-version") + where middleware :: Env -> Wai.Middleware middleware e = - versionMiddleware + versionMiddleware (fold (o ^. optSettings . setDisabledAPIVersions)) . servantPrometheusMiddleware (Proxy @CombinedAPI) . GZip.gzip GZip.def . catchErrors (e ^. appLogger) [Right $ e ^. metrics] diff --git a/services/galley/src/Galley/Options.hs b/services/galley/src/Galley/Options.hs index edb3850d29..844ca39064 100644 --- a/services/galley/src/Galley/Options.hs +++ b/services/galley/src/Galley/Options.hs @@ -25,6 +25,7 @@ module Galley.Options setExposeInvitationURLsTeamAllowlist, setMaxConvSize, setIntraListing, + setDisabledAPIVersions, setConversationCodeURI, setConcurrentDeletionEvents, setDeleteConvThrottleMillis, @@ -66,6 +67,7 @@ import Imports import System.Logger.Extended (Level, LogFormat) import Util.Options import Util.Options.Common +import Wire.API.Routes.Version import Wire.API.Team.Member data Settings = Settings @@ -113,7 +115,8 @@ data Settings = Settings _setEnableIndexedBillingTeamMembers :: !(Maybe Bool), _setMlsPrivateKeyPaths :: !(Maybe MLSPrivateKeyPaths), -- | FUTUREWORK: 'setFeatureFlags' should be renamed to 'setFeatureConfigs' in all types. - _setFeatureFlags :: !FeatureFlags + _setFeatureFlags :: !FeatureFlags, + _setDisabledAPIVersions :: Maybe (Set Version) } deriving (Show, Generic) diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index 81dfa216da..b528a6c054 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -93,7 +93,7 @@ mkApp opts = let logger = env ^. App.applog let middlewares = - versionMiddleware + versionMiddleware (opts ^. optSettings . setDisabledAPIVersions . traverse) . servantPlusWAIPrometheusMiddleware API.sitemap (Proxy @CombinedAPI) . GZip.gunzip . GZip.gzip GZip.def diff --git a/services/gundeck/src/Gundeck/Options.hs b/services/gundeck/src/Gundeck/Options.hs index 3fa6d2044a..95eb235f41 100644 --- a/services/gundeck/src/Gundeck/Options.hs +++ b/services/gundeck/src/Gundeck/Options.hs @@ -28,6 +28,7 @@ import Imports import System.Logger.Extended (Level, LogFormat) import Util.Options import Util.Options.Common +import Wire.API.Routes.Version newtype NotificationTTL = NotificationTTL {notificationTTLSeconds :: Word32} @@ -73,7 +74,8 @@ data Settings = Settings -- ensures that there is only one request every 20 seconds. -- However, that parameter is not honoured when using fake-sqs -- (where throttling can thus make sense) - _setSqsThrottleMillis :: !(Maybe Int) + _setSqsThrottleMillis :: !(Maybe Int), + _setDisabledAPIVersions :: !(Maybe (Set Version)) } deriving (Show, Generic) diff --git a/services/gundeck/src/Gundeck/Run.hs b/services/gundeck/src/Gundeck/Run.hs index 012c7802d0..c8fc2eb908 100644 --- a/services/gundeck/src/Gundeck/Run.hs +++ b/services/gundeck/src/Gundeck/Run.hs @@ -80,7 +80,7 @@ run o = do where middleware :: Env -> Wai.Middleware middleware e = - versionMiddleware + versionMiddleware (fold (o ^. optSettings . setDisabledAPIVersions)) . waiPrometheusMiddleware sitemap . GZip.gunzip . GZip.gzip GZip.def diff --git a/services/proxy/src/Proxy/Options.hs b/services/proxy/src/Proxy/Options.hs index 2397fd0438..58259956ba 100644 --- a/services/proxy/src/Proxy/Options.hs +++ b/services/proxy/src/Proxy/Options.hs @@ -28,6 +28,7 @@ module Proxy.Options logNetStrings, logFormat, mockOpts, + disabledAPIVersions, ) where @@ -36,6 +37,7 @@ import Data.Aeson import Data.Aeson.TH import Imports import System.Logger.Extended (Level (Debug), LogFormat) +import Wire.API.Routes.Version data Opts = Opts { -- | Host to listen on @@ -54,7 +56,8 @@ data Opts = Opts -- | Use netstrings encoding _logNetStrings :: !(Maybe (Last Bool)), -- | choose Encoding - _logFormat :: !(Maybe (Last LogFormat)) + _logFormat :: !(Maybe (Last LogFormat)), + _disabledAPIVersions :: !(Maybe (Set Version)) } deriving (Show, Generic) @@ -73,5 +76,6 @@ mockOpts secrets = _maxConns = 0, _logLevel = Debug, _logNetStrings = pure $ pure $ True, - _logFormat = mempty + _logFormat = mempty, + _disabledAPIVersions = mempty } diff --git a/services/proxy/src/Proxy/Run.hs b/services/proxy/src/Proxy/Run.hs index 69b209b0bb..1eb6f1c1e9 100644 --- a/services/proxy/src/Proxy/Run.hs +++ b/services/proxy/src/Proxy/Run.hs @@ -40,7 +40,7 @@ run o = do let rtree = compile (sitemap e) let app r k = runProxy e r (route rtree r k) let middleware = - versionMiddleware + versionMiddleware (fold (o ^. disabledAPIVersions)) . waiPrometheusMiddleware (sitemap e) . catchErrors (e ^. applog) [Right m] runSettingsWithShutdown s (middleware app) Nothing `finally` destroyEnv e diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 0a81b08c3a..e0a6cd861b 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -69,6 +69,7 @@ import Spar.App import Spar.CanonicalInterpreter import Spar.Error import qualified Spar.Intra.BrigApp as Brig +import Spar.Options import Spar.Orphans () import Spar.Scim import Spar.Sem.AReqIDStore (AReqIDStore) diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 429eac99c0..e92f3f41a9 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -64,6 +64,7 @@ import Servant import qualified Servant.Multipart as Multipart import Spar.Error hiding (sparToServerErrorWithLogging) import qualified Spar.Intra.BrigApp as Intra +import Spar.Options import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.BrigAccess (BrigAccess) diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 02475109a2..75170b8e2d 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -33,6 +33,7 @@ import Polysemy.Input (Input, runInputConst) import Servant import Spar.App hiding (sparToServerErrorWithLogging) import Spar.Error +import Spar.Options import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra) diff --git a/services/spar/src/Spar/Data.hs b/services/spar/src/Spar/Data.hs index 3c53a9d9aa..375533c2f6 100644 --- a/services/spar/src/Spar/Data.hs +++ b/services/spar/src/Spar/Data.hs @@ -44,6 +44,7 @@ import Imports import SAML2.Util (renderURI) import qualified SAML2.WebSSO as SAML import qualified SAML2.WebSSO.Types.Email as SAMLEmail +import Spar.Options import Wire.API.User.Saml -- | A lower bound: @schemaVersion <= whatWeFoundOnCassandra@, not @==@. diff --git a/services/spar/src/Spar/Options.hs b/services/spar/src/Spar/Options.hs index de3eed04fb..ca2da7a3b9 100644 --- a/services/spar/src/Spar/Options.hs +++ b/services/spar/src/Spar/Options.hs @@ -19,26 +19,70 @@ -- with this program. If not, see . -- | Reading the Spar config. --- --- The config type itself, 'Opts', is defined in "Spar.Types". module Spar.Options - ( getOpts, + ( Opts' (..), + Opts, + DerivedOpts (..), + getOpts, deriveOpts, readOptsFile, + maxttlAuthreqDiffTime, ) where import Control.Exception import Control.Lens +import Control.Monad.Except +import Data.Aeson hiding (fieldLabelModifier) import qualified Data.ByteString as SBS +import Data.String.Conversions +import Data.Time import qualified Data.Yaml as Yaml import Imports import Options.Applicative +import SAML2.WebSSO import qualified SAML2.WebSSO as SAML +import System.Logger.Extended (LogFormat) import Text.Ascii (ascii) -import URI.ByteString as URI +import URI.ByteString +import Util.Options +import Wire.API.Routes.Version +import Wire.API.User.Orphans () import Wire.API.User.Saml +type Opts = Opts' DerivedOpts + +data Opts' a = Opts + { saml :: !SAML.Config, + brig :: !Endpoint, + galley :: !Endpoint, + cassandra :: !CassandraOpts, + maxttlAuthreq :: !(TTL "authreq"), + maxttlAuthresp :: !(TTL "authresp"), + -- | The maximum number of SCIM tokens that we will allow teams to have. + maxScimTokens :: !Int, + -- | The maximum size of rich info. Should be in sync with 'Brig.Types.richInfoLimit'. + richInfoLimit :: !Int, + -- | Wire/AWS specific; optional; used to discover Cassandra instance + -- IPs using describe-instances. + discoUrl :: !(Maybe Text), + logNetStrings :: !(Maybe (Last Bool)), + logFormat :: !(Maybe (Last LogFormat)), + disabledAPIVersions :: !(Maybe (Set Version)), + derivedOpts :: !a + } + deriving (Functor, Show, Generic) + +instance FromJSON (Opts' (Maybe ())) + +data DerivedOpts = DerivedOpts + { derivedOptsScimBaseURI :: !URI + } + deriving (Show, Generic) + +maxttlAuthreqDiffTime :: Opts -> NominalDiffTime +maxttlAuthreqDiffTime = ttlToNominalDiffTime . maxttlAuthreq + type OptsRaw = Opts' (Maybe ()) -- | Throws an exception if no config file is found. diff --git a/services/spar/src/Spar/Run.hs b/services/spar/src/Spar/Run.hs index 80e7013291..89b2ff6aec 100644 --- a/services/spar/src/Spar/Run.hs +++ b/services/spar/src/Spar/Run.hs @@ -49,12 +49,12 @@ import Spar.API (API, app) import Spar.App import qualified Spar.Data as Data import Spar.Data.Instances () +import Spar.Options import Spar.Orphans () import System.Logger.Class (Logger) import qualified System.Logger.Extended as Log import Util.Options (casEndpoint, casFilterNodesByDatacentre, casKeyspace, epHost, epPort) import Wire.API.Routes.Version.Wai -import Wire.API.User.Saml as Types import Wire.Sem.Logger.TinyLog ---------------------------------------------------------------------- @@ -62,12 +62,12 @@ import Wire.Sem.Logger.TinyLog initCassandra :: Opts -> Logger -> IO ClientState initCassandra opts lgr = do - let cassOpts = Types.cassandra opts + let cassOpts = cassandra opts connectString <- maybe (Cas.initialContactsPlain (cassOpts ^. casEndpoint . epHost)) (Cas.initialContactsDisco "cassandra_spar" . cs) - (Types.discoUrl opts) + (discoUrl opts) cas <- Cas.init $ Cas.defSettings @@ -115,7 +115,7 @@ mkApp sparCtxOpts = do . Bilge.port (sparCtxOpts ^. to galley . epPort) $ Bilge.empty let wrappedApp = - versionMiddleware + versionMiddleware (fold (disabledAPIVersions sparCtxOpts)) . WU.heavyDebugLogging heavyLogOnly logLevel sparCtxLogger . servantPrometheusMiddleware (Proxy @API) . WU.catchErrors sparCtxLogger [] diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index 313987866d..72c3afa041 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -77,6 +77,7 @@ import Spar.Error ( SparCustomError (SparScimError), SparError, ) +import Spar.Options import Spar.Scim.Auth import Spar.Scim.User import Spar.Sem.BrigAccess (BrigAccess) @@ -96,7 +97,6 @@ import qualified Web.Scim.Schema.Error as Scim import qualified Web.Scim.Schema.Schema as Scim.Schema import qualified Web.Scim.Server as Scim import Wire.API.Routes.Public.Spar -import Wire.API.User.Saml (Opts) import Wire.API.User.Scim import Wire.Sem.Logger (Logger) import Wire.Sem.Now (Now) diff --git a/services/spar/src/Spar/Scim/Auth.hs b/services/spar/src/Spar/Scim/Auth.hs index 0a75c49fb2..12081181f9 100644 --- a/services/spar/src/Spar/Scim/Auth.hs +++ b/services/spar/src/Spar/Scim/Auth.hs @@ -51,6 +51,7 @@ import Servant (NoContent (NoContent), ServerT, (:<|>) ((:<|>))) import Spar.App (throwSparSem) import qualified Spar.Error as E import qualified Spar.Intra.BrigApp as Intra.Brig +import Spar.Options import Spar.Sem.BrigAccess (BrigAccess) import qualified Spar.Sem.BrigAccess as BrigAccess import Spar.Sem.GalleyAccess (GalleyAccess) @@ -63,7 +64,6 @@ import qualified Web.Scim.Handler as Scim import qualified Web.Scim.Schema.Error as Scim import Wire.API.Routes.Public.Spar (APIScimToken) import Wire.API.User as User -import Wire.API.User.Saml (Opts, maxScimTokens) import Wire.API.User.Scim as Api import Wire.Sem.Now (Now) import qualified Wire.Sem.Now as Now diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index c84279743d..2215547ca4 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -69,6 +69,7 @@ import qualified SAML2.WebSSO as SAML import Spar.App (getUserByUrefUnsafe, getUserIdByScimExternalId) import qualified Spar.App import qualified Spar.Intra.BrigApp as Brig +import Spar.Options import Spar.Scim.Auth () import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.Types as ST @@ -102,7 +103,6 @@ import Wire.API.Team.Role import Wire.API.User import Wire.API.User.IdentityProvider (IdP) import qualified Wire.API.User.RichInfo as RI -import Wire.API.User.Saml (Opts, derivedOpts, derivedOptsScimBaseURI, richInfoLimit) import Wire.API.User.Scim (ScimTokenInfo (..)) import qualified Wire.API.User.Scim as ST import Wire.Sem.Logger (Logger) diff --git a/services/spar/src/Spar/Sem/AReqIDStore/Cassandra.hs b/services/spar/src/Spar/Sem/AReqIDStore/Cassandra.hs index a5716e0ca3..8c04ed8692 100644 --- a/services/spar/src/Spar/Sem/AReqIDStore/Cassandra.hs +++ b/services/spar/src/Spar/Sem/AReqIDStore/Cassandra.hs @@ -30,6 +30,7 @@ import Polysemy.Input (Input, input) import qualified SAML2.WebSSO as SAML import qualified Spar.Data as Data import Spar.Data.Instances () +import Spar.Options import Spar.Sem.AReqIDStore import Wire.API.User.Saml import Wire.Sem.Now (Now) diff --git a/services/spar/src/Spar/Sem/AssIDStore/Cassandra.hs b/services/spar/src/Spar/Sem/AssIDStore/Cassandra.hs index ab4a188c55..1465bf8aaf 100644 --- a/services/spar/src/Spar/Sem/AssIDStore/Cassandra.hs +++ b/services/spar/src/Spar/Sem/AssIDStore/Cassandra.hs @@ -30,6 +30,7 @@ import Polysemy.Input import qualified SAML2.WebSSO as SAML import qualified Spar.Data as Data import Spar.Data.Instances () +import Spar.Options import Spar.Sem.AssIDStore import Wire.API.User.Saml import Wire.Sem.Now (Now) diff --git a/services/spar/src/Spar/Sem/SAML2/Library.hs b/services/spar/src/Spar/Sem/SAML2/Library.hs index bb434989e3..993b0fa407 100644 --- a/services/spar/src/Spar/Sem/SAML2/Library.hs +++ b/services/spar/src/Spar/Sem/SAML2/Library.hs @@ -34,6 +34,7 @@ import Polysemy.Internal.Tactics import SAML2.WebSSO hiding (Error) import qualified SAML2.WebSSO as SAML hiding (Error) import Spar.Error (SparCustomError (..), SparError) +import Spar.Options import Spar.Sem.AReqIDStore (AReqIDStore) import qualified Spar.Sem.AReqIDStore as AReqIDStore import Spar.Sem.AssIDStore (AssIDStore) @@ -42,7 +43,6 @@ import Spar.Sem.IdPConfigStore (IdPConfigStore) import qualified Spar.Sem.IdPConfigStore as IdPConfigStore import Spar.Sem.SAML2 import Wire.API.User.IdentityProvider (WireIdP) -import Wire.API.User.Saml import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs index c2e5175354..de1ccc922a 100644 --- a/services/spar/test-integration/Test/Spar/APISpec.hs +++ b/services/spar/test-integration/Test/Spar/APISpec.hs @@ -75,6 +75,7 @@ import SAML2.WebSSO.Test.Lenses import SAML2.WebSSO.Test.MockResponse import SAML2.WebSSO.Test.Util import qualified Spar.Intra.BrigApp as Intra +import Spar.Options import qualified Spar.Sem.AReqIDStore as AReqIDStore import qualified Spar.Sem.BrigAccess as BrigAccess import qualified Spar.Sem.IdPConfigStore as IdPEffect @@ -94,7 +95,6 @@ import Wire.API.User import Wire.API.User.Client import Wire.API.User.Client.Prekey import Wire.API.User.IdentityProvider -import qualified Wire.API.User.Saml as WireAPI (saml) import Wire.API.User.Scim spec :: SpecWith TestEnv @@ -151,7 +151,7 @@ specMetadata = do mkit mdpath finalizepath = do it ("metadata (" <> mdpath <> ")") $ do env <- ask - let sparHost = env ^. teOpts . to WireAPI.saml . SAML.cfgSPSsoURI . to (cs . SAML.renderURI) + let sparHost = env ^. teOpts . to saml . SAML.cfgSPSsoURI . to (cs . SAML.renderURI) fragments = [ "md:SPSSODescriptor", "validUntil", diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs index 494035c42f..46f7fe88e6 100644 --- a/services/spar/test-integration/Test/Spar/DataSpec.hs +++ b/services/spar/test-integration/Test/Spar/DataSpec.hs @@ -33,6 +33,7 @@ import SAML2.WebSSO as SAML import Spar.App as App import Spar.Error (IdpDbError (IdpNotFound), SparCustomError (IdpDbError)) import Spar.Intra.BrigApp (veidFromUserSSOId) +import Spar.Options import qualified Spar.Sem.AReqIDStore as AReqIDStore import qualified Spar.Sem.AssIDStore as AssIDStore import qualified Spar.Sem.IdPConfigStore as IdPEffect diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 3429af354b..342fbbb5cc 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -62,6 +62,7 @@ import qualified SAML2.WebSSO.Test.MockResponse as SAML import SAML2.WebSSO.Test.Util.TestSP (makeSampleIdPMetadata) import qualified SAML2.WebSSO.Test.Util.Types as SAML import qualified Spar.Intra.BrigApp as Intra +import Spar.Options import Spar.Scim import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.User as SU @@ -89,7 +90,6 @@ import Wire.API.User hiding (scimExternalId) import Wire.API.User.IdentityProvider (IdP) import qualified Wire.API.User.IdentityProvider as User import Wire.API.User.RichInfo -import qualified Wire.API.User.Saml as Spar.Types import qualified Wire.API.User.Scim as Spar.Types import qualified Wire.API.User.Search as Search @@ -1585,7 +1585,7 @@ testScimSideIsUpdated = do liftIO $ updatedUser `shouldBe` storedUser' -- Check that the updated user also matches the data that we sent with -- 'updateUser' - richInfoLimit <- view (teOpts . to Spar.Types.richInfoLimit) + richInfoLimit <- view (teOpts . to richInfoLimit) liftIO $ do Right (Scim.value (Scim.thing storedUser')) `shouldBe` (whatSparReturnsFor idp richInfoLimit (setPreferredLanguage defLang user') <&> setDefaultRoleIfEmpty) Scim.id (Scim.thing storedUser') `shouldBe` Scim.id (Scim.thing storedUser) @@ -1641,7 +1641,7 @@ testUpdateSameHandle = do storedUser' <- getUser tok userid liftIO $ updatedUser `shouldBe` storedUser' -- Check that the updated user also matches the data that we sent with 'updateUser' - richInfoLimit <- view (teOpts . to Spar.Types.richInfoLimit) + richInfoLimit <- view (teOpts . to richInfoLimit) liftIO $ do Right (Scim.value (Scim.thing storedUser')) `shouldBe` (whatSparReturnsFor idp richInfoLimit (setPreferredLanguage defLang user') <&> setDefaultRoleIfEmpty) Scim.id (Scim.thing storedUser') `shouldBe` Scim.id (Scim.thing storedUser) diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index 50505ae997..b28898d87a 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -183,7 +183,7 @@ import qualified Spar.App as Spar import Spar.CanonicalInterpreter import Spar.Error (SparError) import qualified Spar.Intra.BrigApp as Intra -import qualified Spar.Options +import Spar.Options import Spar.Run import qualified Spar.Sem.IdPConfigStore as IdPConfigStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore @@ -215,7 +215,6 @@ import qualified Wire.API.User as User import Wire.API.User.Activation import Wire.API.User.Auth hiding (Cookie) import Wire.API.User.IdentityProvider -import Wire.API.User.Saml import Wire.API.User.Scim (runValidExternalIdEither) import Wire.Sem.Logger.TinyLog diff --git a/services/spar/test-integration/Util/Types.hs b/services/spar/test-integration/Util/Types.hs index 04bbfa2978..ba3abc8615 100644 --- a/services/spar/test-integration/Util/Types.hs +++ b/services/spar/test-integration/Util/Types.hs @@ -54,10 +54,10 @@ import Imports import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Spar.API () import qualified Spar.App as Spar +import Spar.Options import Test.Hspec (pendingWith) import Util.Options import Wire.API.User.IdentityProvider (WireIdPAPIVersion) -import Wire.API.User.Saml type BrigReq = Request -> Request From 585a425477934ccccbd76799eac39eb6f9042c69 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Fri, 6 Jan 2023 13:29:04 +0100 Subject: [PATCH 12/33] Convert federation docs to markdown (MyST) (#2962) --- docs/src/_static/css/wire.css | 6 +- .../how-to/install/configure-federation.md | 539 ++++++++++++++++++ .../how-to/install/configure-federation.rst | 471 --------------- docs/src/understand/federation/api.md | 326 +++++++++++ docs/src/understand/federation/api.rst | 283 --------- .../src/understand/federation/architecture.md | 338 +++++++++++ .../understand/federation/architecture.rst | 326 ----------- docs/src/understand/federation/errors.rst | 23 - docs/src/understand/federation/faq.rst | 4 - docs/src/understand/federation/glossary.md | 108 ++++ docs/src/understand/federation/glossary.rst | 111 ---- docs/src/understand/federation/index.rst | 3 +- .../src/understand/federation/introduction.md | 45 ++ .../understand/federation/introduction.rst | 35 -- docs/src/understand/federation/replace.sh | 11 + docs/src/understand/federation/roadmap.md | 112 ++++ docs/src/understand/federation/roadmap.rst | 85 --- 17 files changed, 1481 insertions(+), 1345 deletions(-) create mode 100644 docs/src/how-to/install/configure-federation.md delete mode 100644 docs/src/how-to/install/configure-federation.rst create mode 100644 docs/src/understand/federation/api.md delete mode 100644 docs/src/understand/federation/api.rst create mode 100644 docs/src/understand/federation/architecture.md delete mode 100644 docs/src/understand/federation/architecture.rst delete mode 100644 docs/src/understand/federation/errors.rst delete mode 100644 docs/src/understand/federation/faq.rst create mode 100644 docs/src/understand/federation/glossary.md delete mode 100644 docs/src/understand/federation/glossary.rst create mode 100644 docs/src/understand/federation/introduction.md delete mode 100644 docs/src/understand/federation/introduction.rst create mode 100644 docs/src/understand/federation/replace.sh create mode 100644 docs/src/understand/federation/roadmap.md delete mode 100644 docs/src/understand/federation/roadmap.rst diff --git a/docs/src/_static/css/wire.css b/docs/src/_static/css/wire.css index a28bd8b810..0013ea98e3 100644 --- a/docs/src/_static/css/wire.css +++ b/docs/src/_static/css/wire.css @@ -221,10 +221,6 @@ footer div{ background-color: #c9c9c9; } -.wy-nav-content { - max-width: unset; -} - .wy-nav-top { background-color: #fafafa; color: #34383b; @@ -240,4 +236,4 @@ footer div{ .wy-side-nav-search a:hover { color: #05498f; -} */ \ No newline at end of file +} */ diff --git a/docs/src/how-to/install/configure-federation.md b/docs/src/how-to/install/configure-federation.md new file mode 100644 index 0000000000..38d53af30f --- /dev/null +++ b/docs/src/how-to/install/configure-federation.md @@ -0,0 +1,539 @@ +(configure-federation)= +# Configure Wire-Server for Federation + + +## Background + +Please first understand the current scope and aim of wire-server +federation by reading +{ref}`Understanding federation `. + +```{warning} +As of October 2021, federation implementation is still work in progress. +Many features are not implemented yet, and it should be considered +\"alpha\": stability, and upgrade compatibility are not guaranteed. +``` + + +## Summary of necessary steps to configure federation + +The steps needed to configure federation are as follows and they will be +detailed in the sections below: + +- Choose a backend domain name + +- DNS setup for federation (including an `SRV` record) + +- Generate and configure TLS certificates: + + > - server certificates + > - client certificates + > - a selection of CA certificates you trust when interacting with + > other backends + +- Configure helm charts : federator and ingress and webapp subcharts + +- Test that your configurations work as expected. + +(choose-backend-domain)= +## Choose a {ref}`Backend Domain Name` + +As of the release \[helm chart 0.129.0, Wire docker version 2.94.0\] +from 2020-12-15, a Backend Domain (set as `federationDomain` in +configuration) is a mandatory configuration setting. Regardless of +whether you want to enable federation for a backend or not, you must +decide what its domain is going to be. This helps in keeping things +simpler across all components of Wire and also enables to turn on +federation in the future if required. + +It is highly recommended that this domain is configured as something + * [ ] that is controlled by the administrator/operator(s). The actual servers +do not need to be available on this domain, but you MUST be able to set +an SRV record for `_wire-server-federator._tcp.` that +informs other wire-server backends where to find your actual servers. + +**IMPORTANT**: Once this option is set, it cannot be changed without +breaking experience for all the users which are already using the +backend. + +(consequences-backend-domain)= +## Consequences of the choice of Backend Domain + +- You need control over a specific subdomain of this Backend Domain + (to set an SRV DNS record as explained in the next section). Without + this control, you cannot federate with anyone. + +- This Backend Domain becomes part of the underlying identify of all + users on your servers. + + > - Example: Let\'s say you choose `example.com` as your Backend + > Domain. Your user known to you as Alice, and known on your + > server with ID `ac41a202-2555-11ec-9341-00163e5e6c00` will + > become known for other servers you federate with as + > + > ``` json + > { + > "user": { + > "id": "ac41a202-2555-11ec-9341-00163e5e6c00", + > "domain": "example.com" + > } + > } + > ``` + +- As of October 2021, this domain is used in the User Interface + alongside user information. (This may or may not change in the + future) + + > - Example: Using the same example as above, for backends you + > federate with, Alice would be displayed with the + > human-readable username `@alice@example.com` for users on + > other backends. + +```{warning} +As of October 2021, *changing* this Backend Domain after existing user +activity with a recent version (versions later than \~May/June 2021) +will lead to undefined behaviour (untested, not accounted for during +development) on some or all client platforms (Web, Android, iOS) for +those users: It is possible your clients could crash, or lose part of +their data about themselves or other users and conversations, or +otherwise exhibit unexpected behaviour. If at all possible, do not +change this backend domain. We do not intend to provide support if you +change the backend domain. +``` + + +(dns-configure-federation)= +## DNS setup for federation + +### SRV record + +One prerequisite to enable federation is an [SRV +record](https://en.wikipedia.org/wiki/SRV_record) as defined in [RFC +2782](https://datatracker.ietf.org/doc/html/rfc2782) that needs to be +set up to allow the wire-server to be discovered by other Wire backends. +See the documentation on +{ref}`discovery in federation` for +more information on the role of discovery in federation. + +The fields of the SRV record need to be populated as follows + +- `service`: `wire-server-federator` +- `proto`: `tcp` +- `name`: \ +- `TTL`: e.g. 600 (10 minutes) in an initial phase. This can be set to + a higher value (e.g. 86400) if your systems are stable and DNS + records don\'t change a lot. +- `priority`: anything. A good default value would be 0 +- `weight`: \>0 for your server to be reachable. A good default value + could be 10 +- `port`: `443` +- `target`: \ + +To give an example, assuming + +- your federation + {ref}`Backend Domain ` is `example.com` +- your domains for other services already set up follow the convention + `.wire.example.org` + +then your federation +{ref}`Infra Domain ` +would be `federator.wire.example.org`. + +The SRV record would look as follows: + +``` bash +# _service._proto.name. ttl IN SRV priority weight port target. +_wire-server-federator._tcp.example.com. 600 IN SRV 0 10 443 federator.wire.example.org. +``` + +### DNS A record for the federator + +Background: `federator` is the server component responsible for incoming +and outgoing requests to other backend; but it is proxied on the +incoming requests by the ingress component on kubernetes as shown in +{ref}`Federation Architecture` + +As mentioned in {ref}`DNS setup for Helm`, you also need a `federator.` record, which, +alongside your other DNS records that point to the ingress component, +also needs to point to the IP of your ingress, i.e. the IP you want to +provide services on. + +## Generate and configure TLS server and client certificates + +Are your servers on the public internet? Then you have the option of +using TLS certificates from [Let\'s encrypt](https://letsencrypt.org/). +In such a case go to subsection (A). If your servers are not on the +public internet or you would like to use your own CA, go to subsection +(B). + +```{admonition} Note + +As of Jan 2022, we\'re using the +[hs-tls](https://hackage.haskell.org/package/tls) library for outgoing TLS +connections to other backends, which only supports P256 for ECDSA keys. +Therefore, we have specified a [key size of 256 +bits](https://github.com/wireapp/wire-server/blob/096c48c1f9b6b01572c737bd296dddd7cb5ddabb/charts/nginx-ingress-services/templates/certificate_federator.yaml) +with the use of let\'s encrypt (section A below, you don\'t need to do +anything further). The key size will be visible when inspecting your +certificate as a block looking similar to the following: + + Subject Public Key Info: + Public Key Algorithm: id-ecPublicKey + Public-Key: (256 bit) + ASN1 OID: prime256v1 + NIST CURVE: P-256 + +or: + + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + RSA Public-Key: (2048 bit) + +If you create your own certificates, and use ECDSA as the algorithm, +please ensure you configure a key size of 256 for the time being (There +are no restrictions to key sizes if you\'re using RSA keys, but key +sizes larger than 3000 bit are recommended). + +For details on cipher configuration, see {ref}`tls`. + +Improvements to the TLS setup are planned (TLS 1.3 support; no +restrictions on key sizes anymore), those are tracked internally under +FS-33 and FS-49 (tickets only visible to Wire employees). + +``` + + +### (A) Let\'s encrypt TLS server and client certificate generation and renewal + +The following will make use of [Let\'s +encrypt](https://letsencrypt.org/) for both server certificates (used +when someone sends a request to your `federator.`) and +client certificates (used for making outgoing requests to other +backends). + +For that, you need to have +[jetstack/cert-manager](https://github.com/jetstack/cert-manager) +installed. You can follow the helm chart installation +[here](https://cert-manager.io/docs/installation/helm/). + +Once you have cert-manager, adjust the email address below, then set the +following in the nginx-ingress-services overrides: + +``` yaml +# override values for nginx-ingress-services +# (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) +tls: + useCertManager: true + +certManager: + inTestMode: false + certmasterEmail: "certificates@example.com" +``` + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +federator: + tls: + useSharedFederatorSecret: true +``` + +You can now skip section (B) and go to Configure CA certificates you +trust when interacting with other backends. + +### (B) Manual server and client certificates + +Use your usual method of obtaining X.509 certificates for your {ref}`federation +infra domain ` (alongside the other domains needed for a +wire-server installation). + +You can use one single certificate and key for both server and client +certificate use. + +```{note} +Currently (October 2021), due to a limitation of the TLS library in use +for federation ([hs-tls](https://github.com/vincenthz/hs-tls)), only +some ciphers are supported. Moving to an openssl-based library is +planned, which will provide support for a wider range of ciphers. +``` + +Your certificates need to have the \"Server\" and \"Client\" key usage +listed among the X509 extensions: + +``` bash +# inspect your certificate: +openssl x509 -inform pem -noout -text < your-certificate.pem +``` + +``` bash +X509v3 extensions: + X509v3 Key Usage: critical + Digital Signature, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Server Authentication, TLS Web Client Authentication +``` + +And your {ref}`federation infra domain ` (e.g. +`federator.wire.example.com` from the running example) needs to either figure +explictly in the list of your SAN (Subject Alternative Name): + +``` bash +X509v3 Subject Alternative Name: + DNS:federator.wire.example.com, DNS:nginz-https.wire.example.com, ... +``` + +Or you need to have a wildcard certificate that includes it: + +``` bash +X509v3 Subject Alternative Name: critical + DNS:*.wire.example.com +``` + +Configure the *client certificate* and *private key* inside +wire-server/federator: + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml or helm_vars/wire-server/secrets.yaml) +federator: + clientCertificateContents: | + -----BEGIN CERTIFICATE----- + ..... + -----END CERTIFICATE----- + clientPrivateKeyContents: | + -----BEGIN RSA PRIVATE KEY----- + ..... + -----END RSA PRIVATE KEY----- +``` + +The *server certificate* and *private key* need to be configured in +`nginx-ingress-services`. Those are used for all of the services, not +just the federator component. If you have installed wire-server before +without federation, server certificates may already be configured +*(though you probably need to create new certificates to include the +federation infra domain if you\'re not making use of wildcard +certificates)*. Server certificates go here: + +``` yaml +# override values for nginx-ingress-services +# (e.g. under ./helm_vars/nginx-ingress-services/secrets.yaml) +secrets: + tlsWildcardCert: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + + tlsWildcardKey: | + -----BEGIN RSA PRIVATE KEY ----- + ... + -----END RSA PRIVATE KEY----- +``` + +### Configure CA certificates you trust when interacting with other backends + +If you want to federate with servers at `othercompany.example.com`, then +you need to trust the CA (Certificate Authority) certificate that +`othercompany.example.com` has used to sign its client certificates. + +They need to be set both for the nginx-ingress-services and the +wire-server chart. + +``` yaml +# override values for nginx-ingress-services +# (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) +secrets: + tlsClientCA: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- +``` + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +federator: + remoteCAContents: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- +``` + +### Tell parties you intend to federate with about your certificates + +The backends you want to federate with should add your (or Let\'s +Encrypt\'s) CA to their store, so you should give them your CA +certificate, or tell them to use the appropriate Let\'s Encrypt root +certificate. + +## Configure helm charts: federator and ingress and webapp subcharts + +### Set your chosen backend domain + +Read {ref}`choose-backend-domain` again, then +set the backend domain three times to the same value in the subcharts +cargohold, galley and brig. You also need to set `enableFederator` to +`true`. + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +galley: + config: + enableFederator: true + settings: + federationDomain: example.com # your chosen "backend domain" + +brig: + config: + enableFederator: true + optSettings: + setFederationDomain: example.com # your chosen "backend domain" + +cargohold: + config: + enableFederator: true + settings: + federationDomain: example.com # your chosen "backend domain" +``` + +### Configure federator process to run and allow incoming traffic + +For federation to work, the `federator` subchart of wire-server has to +be enabled: + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +tags: + federator: true +``` + +You also need to enable ingress-\>federator proxying and configure the +charts to use the DNS you configured as a target in +{ref}`dns-configure-federation` above + +``` yaml +# override values for nginx-ingress-services +# (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) +federator: + enabled: true + +config: + dns: + federator: federator.wire.example.org # set this to your "infra" domain +``` + +### Configure the validation depth when handling client certificates + +By default, `verify_depth` is `1`, meaning that in order to validate an +incoming request from another backend, this backend needs to have a +client certificate that is directly (without any intermediate +certificates) signed by a CA certificate from the trust store. + +Example: If you trust a CA `root` which signs an intermediate +`intermediate-1` which in turn signs `intermediate-2` which finally +signs `leaf`, and `leaf` is used during mutual TLS when validating +incoming requests, then `verify_depth` would need to be set to `3`. + +``` yaml +# nginx-ingress-services/values.yaml +tls: + # the validation depth between a federator client certificate and tlsClientCA + verify_depth: 3 # default: 1 +``` + +### Configure the allow list + +By default, federation is turned off (allow list set to the empty list): + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +federator: + config: + optSettings: + federationStrategy: + allowedDomains: [] +``` + +You can choose to federate with a specific list of allowed backends: + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +federator: + config: + optSettings: + federationStrategy: + allowedDomains: + - example.com + - example.org +``` + +Alternatively, you can federate with everyone: + +``` yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +federator: + config: + optSettings: + federationStrategy: + allowAll: true +``` + +## Applying all configuration changes + +Depending on your installation method and time you initially installed +your first version of wire-server, commands to run to apply all of the +above configrations may vary. You want to ensure that you upgrade the +`nginx-ingress-services` and `wire-server` helm charts at a minimum. + +## Manually test that your configurations work as expected + +### Manually test DNS + +If you use `dig` to check for SRV records, use e.g.: + + dig +short SRV _wire-server-federator._tcp.wire.example.com + +Should yield something like: + + 0 10 443 federator.wire.example.com. + +The actual target: + + dig +short federator.wire.example.com + +should also point to an IP address: + + 1.2.3.4 # of course you should get a valid IP here + +Ensure that the IP matches where your backend ingress runs. + +### Manually test certificates + +Refer to {ref}`how-to-see-tls-certs` and set +DOMAIN to your +{ref}`federation infra domain `. They should include your domain as part of the SAN (Subject +Alternative Names) and not have expired. + +### Manually test that federation \"works\" + +Prerequisites: + +- You need two backends with federation configured and enabled. +- They both need to have each other in the allow list. +- They both need to trust each other\'s CA certificate. + +Create user accounts on both backends. + +With one user, search for the other user using the +`@username-1@example.com` syntax in the UI search field of the webapp. diff --git a/docs/src/how-to/install/configure-federation.rst b/docs/src/how-to/install/configure-federation.rst deleted file mode 100644 index d9aa10d8c8..0000000000 --- a/docs/src/how-to/install/configure-federation.rst +++ /dev/null @@ -1,471 +0,0 @@ -.. _configure-federation: - -Configure Wire-Server for federation -===================================== - -Background ------------ - -Please first understand the current scope and aim of wire-server federation by reading :ref:`Understanding federation `. - -.. warning:: As of October 2021, federation implementation is still work in progress. Many features are not implemented yet, - and it should be considered "alpha": stability, and upgrade compatibility are not guaranteed. - -Summary of necessary steps to configure federation --------------------------------------------------- - -The steps needed to configure federation are as follows and they will be detailed in the sections below: - -* Choose a backend domain name -* DNS setup for federation (including an ``SRV`` record) -* Generate and configure TLS certificates: - - * server certificates - * client certificates - * a selection of CA certificates you trust when interacting with other backends - -* Configure helm charts : federator and ingress and webapp subcharts -* Test that your configurations work as expected. - -.. _choose-backend-domain: - -Choose a :ref:`Backend Domain Name` ------------------------------------------------------------- - -As of the release [helm chart 0.129.0, Wire docker version 2.94.0] from -2020-12-15, a Backend Domain (set as ``federationDomain`` in configuration) is a -mandatory configuration setting. Regardless of whether you want to enable -federation for a backend or not, you must decide what its domain is going to be. -This helps in keeping things simpler across all components of Wire and also -enables to turn on federation in the future if required. - -It is highly recommended that this domain is configured as -something that is controlled by the administrator/operator(s). The actual -servers do not need to be available on this domain, but you MUST be able to set -an SRV record for ``_wire-server-federator._tcp.`` that -informs other wire-server backends where to find your actual servers. - -**IMPORTANT**: Once this option is set, it cannot be changed without breaking -experience for all the users which are already using the backend. - -.. _consequences-backend-domain: - -Consequences of the choice of Backend Domain --------------------------------------------- - -* You need control over a specific subdomain of this Backend Domain (to set an - SRV DNS record as explained in the next section). Without this control, you cannot federate with anyone. - -* This Backend Domain becomes part of the underlying identify of all users on - your servers. - - * Example: Let's say you choose ``example.com`` as your Backend Domain. - Your user known to you as Alice, and known on your server with ID - ``ac41a202-2555-11ec-9341-00163e5e6c00`` will become known for other - servers you federate with as - - .. code:: json - - { - "user": { - "id": "ac41a202-2555-11ec-9341-00163e5e6c00", - "domain": "example.com" - } - } - -* As of October 2021, this domain is used in the User Interface alongside user information. - (This may or may not change in the future) - - * Example: Using the same example as above, for backends you federate with, Alice - would be displayed with the human-readable username ``@alice@example.com`` - for users on other backends. - -.. warning :: - - As of October 2021, *changing* this Backend Domain after existing user activity - with a recent version (versions later than ~May/June 2021) will lead to undefined - behaviour (untested, not accounted for during development) on some or all - client platforms (Web, Android, iOS) for those users: It is possible your - clients could crash, or lose part of their data about themselves or other - users and conversations, or otherwise exhibit unexpected behaviour. If at - all possible, do not change this backend domain. We do not intend to - provide support if you change the backend domain. - - -.. _dns-configure-federation: - -.. include:: ./includes/dns-federation.rst - -Generate and configure TLS server and client certificates ---------------------------------------------------------- - -Are your servers on the public internet? Then you have the option of using TLS certificates from `Let's encrypt -`__. In such a case go to subsection (A). If your servers are not on the public internet -or you would like to use your own CA, go to subsection (B). - -.. note:: - - As of Jan 2022, we're using the `hs-tls ` library for outgoing TLS connections to other backends, which only supports P256 for ECDSA keys. - Therefore, we have specified a `key size of 256 bits `__ with the use of let's encrypt (section A below, you don't need to do anything further). The key size will be visible when inspecting your certificate as a block looking similar to the following:: - - Subject Public Key Info: - Public Key Algorithm: id-ecPublicKey - Public-Key: (256 bit) - ASN1 OID: prime256v1 - NIST CURVE: P-256 - - or:: - - Subject Public Key Info: - Public Key Algorithm: rsaEncryption - RSA Public-Key: (2048 bit) - - If you create your own certificates, and use ECDSA as the algorithm, please ensure you configure a key size of 256 for the time being (There are no restrictions to key sizes if you're using RSA keys, but key sizes larger than 3000 bit are recommended). - - - For details on cipher configuration, see :ref:`tls`. - - Improvements to the TLS setup are planned (TLS 1.3 support; no restrictions on key sizes anymore), those are tracked internally under FS-33 and FS-49 (tickets only visible to Wire employees). - - -(A) Let's encrypt TLS server and client certificate generation and renewal -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The following will make use of `Let's encrypt `__ for both server certificates (used when -someone sends a request to your ``federator.``) and client certificates (used for making outgoing requests -to other backends). - -For that, you need to have `jetstack/cert-manager `__ installed. You can -follow the helm chart installation `here `__. - -Once you have cert-manager, adjust the email address below, then set the following in the nginx-ingress-services overrides: - -.. code:: yaml - - # override values for nginx-ingress-services - # (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) - tls: - useCertManager: true - - certManager: - inTestMode: false - certmasterEmail: "certificates@example.com" - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - federator: - tls: - useSharedFederatorSecret: true - -You can now skip section (B) and go to Configure CA certificates you trust when interacting with other backends. - -(B) Manual server and client certificates -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use your usual method of obtaining X.509 certificates for your :ref:`federation infra domain -` (alongside the other domains needed for a wire-server installation). - -You can use one single certificate and key for both server and client certificate use. - -.. note:: - - Currently (October 2021), due to a limitation of the TLS library in use for federation (`hs-tls - `__), only some ciphers are supported. Moving to an - openssl-based library is planned, which will provide support for a wider range of ciphers. - -.. - TODO: provide a list of supported ciphers and signature algorithms. - -Your certificates need to have the "Server" and "Client" key usage listed among the X509 extensions: - -.. code:: bash - - # inspect your certificate: - openssl x509 -inform pem -noout -text < your-certificate.pem - -.. code:: bash - - X509v3 extensions: - X509v3 Key Usage: critical - Digital Signature, Key Encipherment - X509v3 Extended Key Usage: - TLS Web Server Authentication, TLS Web Client Authentication - -And your :ref:`federation infra domain ` (e.g. ``federator.wire.example.com`` -from the running example) needs to either figure explictly in the list of your SAN (Subject -Alternative Name): - -.. code:: bash - - X509v3 Subject Alternative Name: - DNS:federator.wire.example.com, DNS:nginz-https.wire.example.com, ... - -Or you need to have a wildcard certificate that includes it: - -.. code:: bash - - X509v3 Subject Alternative Name: critical - DNS:*.wire.example.com - -Configure the *client certificate* and *private key* inside wire-server/federator: - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml or helm_vars/wire-server/secrets.yaml) - federator: - clientCertificateContents: | - -----BEGIN CERTIFICATE----- - ..... - -----END CERTIFICATE----- - clientPrivateKeyContents: | - -----BEGIN RSA PRIVATE KEY----- - ..... - -----END RSA PRIVATE KEY----- - -The *server certificate* and *private key* need to be configured in ``nginx-ingress-services``. Those are used for all -of the services, not just the federator component. If you have installed -wire-server before without federation, server certificates may already be configured *(though you probably need to create -new certificates to include the federation infra domain if you're not making use of wildcard certificates)*. Server -certificates go here: - -.. code:: yaml - - # override values for nginx-ingress-services - # (e.g. under ./helm_vars/nginx-ingress-services/secrets.yaml) - secrets: - tlsWildcardCert: | - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - - tlsWildcardKey: | - -----BEGIN RSA PRIVATE KEY ----- - ... - -----END RSA PRIVATE KEY----- - - -Configure CA certificates you trust when interacting with other backends -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -If you want to federate with servers at ``othercompany.example.com``, then you need to trust the CA (Certificate Authority) -certificate that ``othercompany.example.com`` has used to sign its client certificates. - -They need to be set both for the nginx-ingress-services and the wire-server chart. - -.. code:: yaml - - # override values for nginx-ingress-services - # (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) - secrets: - tlsClientCA: | - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - federator: - remoteCAContents: | - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -Tell parties you intend to federate with about your certificates -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The backends you want to federate with should add your (or Let's Encrypt's) CA -to their store, so you should give them your CA certificate, or tell them to use -the appropriate Let's Encrypt root certificate. - -Configure helm charts: federator and ingress and webapp subcharts ------------------------------------------------------------------ - -Set your chosen backend domain -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Read :ref:`choose-backend-domain` again, then set the backend domain three times -to the same value in the subcharts cargohold, galley and brig. You also need to -set ``enableFederator`` to ``true``. - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - galley: - config: - enableFederator: true - settings: - federationDomain: example.com # your chosen "backend domain" - - brig: - config: - enableFederator: true - optSettings: - setFederationDomain: example.com # your chosen "backend domain" - - cargohold: - config: - enableFederator: true - settings: - federationDomain: example.com # your chosen "backend domain" - -Configure the webapp to enable federation and set your chosen backend domain one more time -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - webapp: - envVars: - FEATURE_FEDERATION_DOMAIN: "example.com" # your chosen "backend domain" - FEATURE_ENABLE_FEDERATION: "true" - -Configure federator process to run and allow incoming traffic -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -For federation to work, the ``federator`` subchart of wire-server has to be enabled: - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - tags: - federator: true - -You also need to enable ingress->federator proxying and configure the charts to use the DNS you configured as a target -in :ref:`dns-configure-federation` above - -.. code:: yaml - - # override values for nginx-ingress-services - # (e.g. under ./helm_vars/nginx-ingress-services/values.yaml) - federator: - enabled: true - - config: - dns: - federator: federator.wire.example.org # set this to your "infra" domain - -Configure the validation depth when handling client certificates -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -By default, ``verify_depth`` is ``1``, meaning that in order to validate an incoming request from another backend, this backend needs to have a client certificate that is directly (without any intermediate certificates) signed by a CA certificate from the trust store. - -Example: If you trust a CA ``root`` which signs an intermediate ``intermediate-1`` which in turn signs ``intermediate-2`` which finally signs ``leaf``, and ``leaf`` is used during mutual TLS when validating incoming requests, then ``verify_depth`` would need to be set to ``3``. - -.. code:: yaml - - # nginx-ingress-services/values.yaml - tls: - # the validation depth between a federator client certificate and tlsClientCA - verify_depth: 3 # default: 1 - -Configure the allow list -^^^^^^^^^^^^^^^^^^^^^^^^ - -By default, federation is turned off (allow list set to the empty list): - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - federator: - config: - optSettings: - federationStrategy: - allowedDomains: [] - -You can choose to federate with a specific list of allowed backends: - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - federator: - config: - optSettings: - federationStrategy: - allowedDomains: - - example.com - - example.org - -Alternatively, you can federate with everyone: - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - federator: - config: - optSettings: - federationStrategy: - allowAll: true - - -Applying all configuration changes ----------------------------------- - -Depending on your installation method and time you initially installed your first version of wire-server, commands to -run to apply all of the above configrations may vary. You want to ensure that you upgrade the ``nginx-ingress-services`` -and ``wire-server`` helm charts at a minimum. - -Manually test that your configurations work as expected -------------------------------------------------------- - -Manually test DNS -^^^^^^^^^^^^^^^^^ - -If you use ``dig`` to check for SRV records, use e.g.:: - - dig +short SRV _wire-server-federator._tcp.wire.example.com - -Should yield something like:: - - 0 10 443 federator.wire.example.com. - -The actual target:: - - dig +short federator.wire.example.com - -should also point to an IP address:: - - 1.2.3.4 # of course you should get a valid IP here - -Ensure that the IP matches where your backend ingress runs. - -Manually test certificates -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Refer to :ref:`how-to-see-tls-certs` and set DOMAIN to your :ref:`federation infra domain `. They -should include your domain as part of the SAN (Subject Alternative Names) and not have expired. - -Manually test that federation "works" -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Prerequisites: - -* You need two backends with federation configured and enabled. -* They both need to have each other in the allow list. -* They both need to trust each other's CA certificate. - -Create user accounts on both backends. - -With one user, search for the other user using the ``@username-1@example.com`` syntax in the UI search field of the -webapp. - -.. - FUTUREWORK - * A way to validate overall helm configuration to be consistent - * A way to test client certificates. diff --git a/docs/src/understand/federation/api.md b/docs/src/understand/federation/api.md new file mode 100644 index 0000000000..a11e38397d --- /dev/null +++ b/docs/src/understand/federation/api.md @@ -0,0 +1,326 @@ +(federation-api)= + +# API + +The Federation API consists of two *layers*: + +1. Between two backends (i.e. between a *Federator* and a + *Federation Ingress*) +2. Between backend-internal components + +(qualified-identifiers-and-names)= + +## Qualified Identifiers and Names + +The federated (and consequently distributed) architecture is reflected +in the structure of the various identifiers and names used in the API. +Before federation, identifiers were only unique in the context of a +single backend; for federation, they are made globally unique by +combining them with the federation domain of their backend. We call +these combined identifiers *qualified* identifiers. While other parts of +some identifiers or names may change, the domain name (i.e. the +qualifying part) is static. + +In particular, we use the following identifiers throughout the API: + +- {ref}`glossary_qualified-user-id`: *user_uuid@backend-domain.com* +- {ref}`glossary_qualified-user-name`: *user_name@backend-domain.com* +- {ref}`glossary_qualified-client-id` attached to a QUID: *client_uuid.user_uuid@backend-domain.com* +- {ref}`glossary_qualified-conversation-id` / {ref}`glossary_qualified-group-id`: *backend-domain.com/groups/group_uuid* +- {ref}`glossary_qualified-team-id`: *backend-domain.com/teams/team_uuid* + +While the canonical representation for purposes of visualization is as +displayed above, the API often decomposes the qualified identifiers into +an (unqualified) id and a domain name. In the code and API +documentation, we sometimes call a username a \"handle\" and a qualified +username a \"qualified handle\". + +Besides the above names and identifiers, there are also user +{ref}`glossary_display-name` (sometimes also +referred to as \"profile names\"), which are not unique on the user\'s +backend, can be changed by the user at any time and are not qualified. + +(api-between-federators)= + +## API between Federators + +The layer between *Federator* acts as an envelope for communication +between other components of wire server. The *Inward* service of +*Federator* is an HTTP2 server which is responsible for accepting +external requests coming from other backends, and forwarding them to the +appropriate component (currently Brig or Galley). + +*Federator* inspects the header of an incoming requests, performs +discovery and authentication, as described in +{ref}`Backend to backend communication +`, then +forwards the request as-is by repackaging its body into an HTTP request +for the target component. + +The *Inward* service accepts only `POST` requests with a path of the +form `/federation/:component/:rpc`, where `:component` is the lowercase +name of the target component (i.e. `brig` or `galley`), and `:rpc` is +the name of the federation RPC to invoke. The arguments of the RPC are +contained the body, which is assumed to be of content type +`application/json`. + +See {ref}`api-from-federator-to-components` for more details on RPCs and their paths. + +(api-from-components-to-federator)= + +## API From Components to Federator + +Between two federated backends, the components talk to each other via the +*Federator* in the originating domain and *Ingress* in the receiving domain. +When making the call to the *Federator*, the components use HTTP2. They call the +`Outward` service, which accepts `POST` requests with path +`/rpc/:domain/:component/:rpc`. Such a request will be forwarded to a remote +federator with the given {ref}`Backend-domains`, and converted to the +appropriate request for its `Inward` service. + +(api-from-federator-to-components)= + +## API From Federator to Components + +The components expose a REST API over HTTP to be consumed by the +*Federator*. All the paths start with `/federation`. When a *Federator* +receives a `POST` request to `/federation/brig/get-user-by-handle`, it +connects to a local Brig and forwards the request to it after changing +its path to `/federation/get-user-by-handle`. + +The `/federation` prefix is kept in the path to allow the component to +distinguish federated requests from requests by clients or other local +components. + +If this request succeeds, the response is directly used as a response +for the original call to the `Inward` service. Otherwise, a response +with a `5xx` status code is returned, with a body containing a +description of the error that has occurred. + +Note that the name of the RPC (`get-user-by-handle` in the above +example) is required to be a single path segment consisting of only +ASCII characters within a restricted set. This prevents path-traversal +attacks such as attempting to access `/federation/../users/by-handle`. + +(api-endpoints)= + +## List of Federation APIs exposed by Components + +Each component of the backend provides an API towards the *Federator* +for access by other backends. For example on how these APIs are used, +see the section on +`end-to-end flows`{.interpreted-text role="ref"}. + + +```{note} +This reflects status of API endpoints as of 2022-01-28. For latest APIs please +refer to the corresponding source code linked in the individual section. +``` + +(brig)= + +### Brig + +In its current state, the primary purpose of the Brig API is to allow +users of remote backends to create conversations with the local users of +the backend. + +- `get-user-by-handle`: Given a handle, return the user profile + corresponding to that handle. +- `get-users-by-ids`: Given a list of user ids, return the list of + corresponding user profiles. +- `claim-prekey`: Given a user id and a client id, return a Proteus + pre-key belonging to that user. +- `claim-prekey-bundle`: Given a user id, return a prekey for each of + the user\'s clients. +- `claim-multi-prekey-bundle`: Given a list of user ids, return + prekeys of their respective clients. +- `search-users`: Given a term, search the user database for matches + w.r.t. that term. +- `get-user-clients`: Given a list of user ids, return the lists of + clients of each of the users. + +See [the brig source +code](https://github.com/wireapp/wire-server/blob/master/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs) +for the current list of federated endpoints of the *Brig*, as well as +their precise inputs and outputs. + +(galley)= + +### Galley + +Each backend keeps a record of the conversations that each of its +members is a part of. The purpose of the Galley API is to allow backends +to synchronize the state of the conversations of their members. + +- `on-conversation-created`: Given a name and a list of conversation + members, create a conversation locally. This is used to inform + another backend of a new conversation that involves their local + user(s). +- `get-conversations`: Given a qualified user id and a list of + conversation ids, return the details of the conversations. This + allows a remote backend to query conversation metadata of their + local user from this backend. To avoid metadata leaks, the backend + will check that the domain of the given user corresponds to the + domain of the backend sending the request. +- `on-conversation-updated`: Given a qualified user id and a qualified + conversation id, update the conversation details locally with the + other data provided. This is used to alert remote backend of updates + in the conversation metadata of conversations in which at least one + of their local users is involved. +- `leave-conversation`: Given a remote user and a conversation id, + remove the the remote user from the (local) conversation. +- `on-message-sent`: Given a remote message and a conversation id, + propagate a message to local users. This is used whenever there is a + remote user in a conversation (see end-to-end flows). +- `send-message`: Given a sender and a raw message request, send a + message to a conversation owned by another backend. This is used + when the user sending a message is not on the same backend as the + conversation the message is sent in. + +See [the galley source +code](https://github.com/wireapp/wire-server/blob/master/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs) +for the current list of federated endpoints of the *Galley*, as well as +their precise inputs and outputs. + +(end-to-end-flows)= + +## End-to-End Flows + +In the following end-to-end flows, we focus on the interaction between +the Brigs and Galleys of federated backends. While the interactions are +facilitated by the *Federator* and *Federation Ingress* components of +the backends involved, which handle the necessary discovery, +authentication and authorization steps, we won\'t mention these steps +explicitly each time to keep the flows simple. + +Additionally we assume that the backend domain and the infra domain of +the respective backends involved are the same and each domain identifies +a distinct backend. + +(user-discovery)= + +### User Discovery + +In this flow, the user *A* at *backend-a.com* tries to search for user +*B* at *backend-b.com*. + +1. User *A@backend-a.com* enters the qualified user name of the target + user *B@backend-b.com* into the search field of their Wire client. +2. The client issues a query to `/search/contacts` of the Brig + searching for *B* at *backend-b.com*. +3. The Brig in *A*\'s backend asks its local *Federator* to query the + `search-users` endpoint of B\'s backend for *B*. +4. *A*\'s *Federator* queries *B*\'s Brig via *B*\'s *Federation + Ingress* and *Federator* as requested. +5. *B*\'s Brig replies with *B*\'s user name and qualified handle, the + response goes through *B*\'s *Federator* and *Federation Ingress*, + as well as *A*\'s *Federator* before it reaches *A*\'s Brig. +6. *A*\'s Brig forwards that information to *A*\'s client. + +(conversation-establishment)= + +### Conversation Establishment + +After having discovered user *B* at *backend-b.com*, user *A* at +*backend-a.com* wants to establish a conversation with *B*. + +1. From the search results of a + {ref}`user discovery` + process, *A* chooses to create a conversation with *B*. +2. *A*\'s client issues a `/users/backend-b.com/B/prekeys` query to + *A*\'s Brig. +3. *A*\'s Brig asks its *Federator* to query the `claim-prekey-bundle` + endpoint of *B*\'s backend using *B*\'s user id. +4. *B*\'s *Federation Ingress* forwards the query to the *Federator*, + who in turn forwards it to the local Brig. +5. *B*\'s Brig replies with a prekey bundle for each of *B*\'s clients, + which is forwarded to *A*\'s Brig via *B*\'s *Federator* and + *Federation Ingress*, as well as *A*\'s *Federator*. +6. *A*\'s Brig forwards that information to *A*\'s client. +7. *A*\'s client queries the `/conversations` endpoint of its Galley + using *B*\'s user id. +8. *A*\'s Galley creates the conversation locally and queries the + `on-conversation-created` endpoint of *B*\'s Galley (again via its + local *Federator*, as well as *B*\'s *Federation Ingress* and + *Federator*) to inform it about the new conversation, including the + conversation metadata in the request. +9. *B*\'s Galley registers the conversation locally and confirms the + query. +10. *B*\'s Galley notifies *B*\'s client of the creation of the + conversation. + +(message-sending-a)= + +### Message Sending (A) + +Having established a conversation with user *B* at *backend-b.com*, user +*A* at *backend-a.com* wants to send a message to user *B*. + +1. In a conversation *conv-1@backend-a.com* on *A*\'s backend with + users *A@backend-a.com* and *B@backend-b.com*, *A* sends a message + by using the `/conversations/backend-a.com/conv-1/proteus/messages` + endpoint on *A*\'s Galley. +2. *A*\'s Galley checks if *A* included all necessary user devices in + their request. For that it makes a `get-user-clients` request to + *B*\'s Galley. *A*\'s Galley checks that the returned list of + clients matches the list of clients the message was encrypted for. +3. *A*\'s Galley sends the message to all clients in the conversation + that are part of *A*\'s backend. +4. *A*\'s Galley queries the `on-message-sent` endpoint on *B*\'s + Galley via its *Federator* and *B*\'s *Federation Ingress* and + *Federator*. +5. *B*\'s Galley will propagate the message to all local clients + involved in the conversation. + +(message-sending-b)= + +### Message Sending (B) + +Having received a message from user *A* at *backend-a.com*, user *B* at +*backend-b.com* wants send a reply. + +1. In a conversation *conv-1@backend-a.com* on *A*\'s backend with + users *A@backend-a.com* and *B@backend-b.com*, *B* sends a message + by using the `/conversations/backend-a.com/conv-1/proteus/messages` + endpoint on *B*\'s backend. +2. *B*\'s Galley queries the `send-message` endpoint on *A*\'s backend. + *Steps 3-6 below are essentially the same as steps 2-5 in Message + Sending (A)* +3. *A*\'s Galley checks if *A* included all necessary user devices in + their request. For that it makes a `get-user-clients` request to + *B*\'s Galley. *A*\'s Galley checks that the returned list of + clients matches the list of clients the message was encrypted for. +4. *A*\'s Galley sends the message to all clients in the conversation + that are part of *A*\'s backend. +5. *A*\'s Galley queries the `on-message-sent` endpoint on *B*\'s + Galley via its *Federator* and *B*\'s *Federation Ingress* and + *Federator*. +6. *B*\'s Galley will propagate the message to all local clients + involved in the conversation. + +(error-codes)= + +## Error Codes + +This page describes the errors that can occur during federation. + +(authentication-errors)= + +### Authentication Errors + +TODO for now, we only describe the errors here. Later, we should add +exact error codes. + +TODO we might want to merge one or more of these errors + +- *authentication error*: occurs when a backend queries another + backend and provides either no client certificate, or a client + certificate that the receiving backend cannot authenticate +- *authorization error*: occurs when a sending backend + authenticates successfully, but is not on the allow list of the + receiving backend +- *discovery error*: occurs when a sending backend authenticates + successfully, but the [SRV]{.title-ref} record published for the + claimed domain of the sending backend doesn\'t match the SAN of the + sending backend\'s client certificate diff --git a/docs/src/understand/federation/api.rst b/docs/src/understand/federation/api.rst deleted file mode 100644 index 55e8d2aa77..0000000000 --- a/docs/src/understand/federation/api.rst +++ /dev/null @@ -1,283 +0,0 @@ -.. _federation-api: - -API -==== - -The Federation API consists of two *layers*: - 1. Between two backends (i.e. between a `Federator` and a `Federation - Ingress`) - 2. Between backend-internal components - -.. _qualified-identifiers-and-names: - -Qualified Identifiers and Names -------------------------------- - -The federated (and consequently distributed) architecture is reflected in the -structure of the various identifiers and names used in the API. Before -federation, identifiers were only unique in the context of a single backend; for -federation, they are made globally unique by combining them with the federation -domain of their backend. We call these combined identifiers *qualified* -identifiers. While other parts of some identifiers or names may change, the -domain name (i.e. the qualifying part) is static. - -In particular, we use the following identifiers throughout the API: - -* :ref:`Qualified User ID ` (QUID): `user_uuid@backend-domain.com` -* :ref:`Qualified User Name ` (QUN): `user_name@backend-domain.com` -* :ref:`Qualified Client ID ` (QDID) attached to a QUID: `client_uuid.user_uuid@backend-domain.com` -* :ref:`Qualified Conversation `/:ref:`Group ID ` (QCID/QGID): `backend-domain.com/groups/group_uuid` -* :ref:`Qualified Team ID ` (QTID): `backend-domain.com/teams/team_uuid` - -While the canonical representation for purposes of visualization is as displayed -above, the API often decomposes the qualified identifiers into an (unqualified) -id and a domain name. In the code and API documentation, we sometimes call a -username a "handle" and a qualified username a "qualified handle". - -Besides the above names and identifiers, there are also user :ref:`display names -` (sometimes also referred to as "profile names"), which are not -unique on the user's backend, can be changed by the user at any time and are not -qualified. - - -API between Federators ------------------------ - -The layer between `Federator` acts as an envelope for communication between -other components of wire server. The `Inward` service of `Federator` is an -HTTP2 server which is responsible for accepting external requests coming from -other backends, and forwarding them to the appropriate component (currently -Brig or Galley). - - -`Federator` inspects the header of an incoming requests, performs discovery and -authentication, as described in :ref:`Backend to backend communication -`, then forwards the request as-is by -repackaging its body into an HTTP request for the target component. - -The `Inward` service accepts only ``POST`` requests with a path of the form -``/federation/:component/:rpc``, where `:component` is the lowercase name of -the target component (i.e. ``brig`` or ``galley``), and ``:rpc`` is the name of -the federation RPC to invoke. The arguments of the RPC are contained the body, -which is assumed to be of content type ``application/json``. - -See :ref:`below ` for more details on RPCs -and their paths. - -API From Components to Federator --------------------------------- - -Between two federated backends, the components talk to each other via the -`Federator` in the originating domain and `Ingress` in the receiving domain. -When making the call to the `Federator`, the components use HTTP2. They call -the ``Outward`` service, which accepts ``POST`` requests with path -``/rpc/:domain/:component/:rpc``. Such a request will be forwarded to a remote -federator with the given :ref:`Backend domain `, and converted -to the appropriate request for its ``Inward`` service. - -.. _api-from-federator-to-components: - -API From Federator to Components --------------------------------- - -The components expose a REST API over HTTP to be consumed by the `Federator`. -All the paths start with ``/federation``. When a `Federator` receives a -``POST`` request to ``/federation/brig/get-user-by-handle``, it connects to a -local Brig and forwards the request to it after changing its path to -``/federation/get-user-by-handle``. - -The ``/federation`` prefix is kept in the path to allow the component to -distinguish federated requests from requests by clients or other local -components. - -If this request succeeds, the response is directly used as a response for the -original call to the ``Inward`` service. Otherwise, a response with a ``5xx`` -status code is returned, with a body containing a description of the error that -has occurred. - -Note that the name of the RPC (``get-user-by-handle`` in the above example) is -required to be a single path segment consisting of only ASCII characters within -a restricted set. This prevents path-traversal attacks such as attempting to -access ``/federation/../users/by-handle``. - -.. _api-endpoints: - -List of Federation APIs exposed by Components ---------------------------------------------- - -Each component of the backend provides an API towards the `Federator` for access -by other backends. For example on how these APIs are used, see the section on -:ref:`end-to-end flows`. - -.. note:: This reflects status of API endpoints as of 2022-01-28. For latest - APIs please refer to the corresponding source code linked in the - individual section. - -.. comment: The endpoints and objects are written manually. FUTUREWORK: Automate - this. - -Brig -^^^^ - -In its current state, the primary purpose of the Brig API is to -allow users of remote backends to create conversations with the local users of -the backend. - -* ``get-user-by-handle``: Given a handle, return the user profile - corresponding to that handle. -* ``get-users-by-ids``: Given a list of user ids, return the list of - corresponding user profiles. -* ``claim-prekey``: Given a user id and a client id, return a Proteus pre-key - belonging to that user. -* ``claim-prekey-bundle``: Given a user id, return a prekey for each of the - user's clients. -* ``claim-multi-prekey-bundle``: Given a list of user ids, return prekeys of - their respective clients. -* ``search-users``: Given a term, search the user database for matches w.r.t. - that term. -* ``get-user-clients``: Given a list of user ids, return the lists of clients of - each of the users. - -See `the brig source code -`_ -for the current list of federated endpoints of the `Brig`, as well as their -precise inputs and outputs. - -Galley -^^^^^^ - -Each backend keeps a record of the conversations that each of its members is a -part of. The purpose of the Galley API is to allow backends to synchronize the -state of the conversations of their members. - -* ``on-conversation-created``: Given a name and a list of conversation members, - create a conversation locally. This is used to inform another backend of a new - conversation that involves their local user(s). -* ``get-conversations``: Given a qualified user id and a list of conversation - ids, return the details of the conversations. This allows a remote backend to - query conversation metadata of their local user from this backend. To avoid - metadata leaks, the backend will check that the domain of the given user - corresponds to the domain of the backend sending the request. -* ``on-conversation-updated``: Given a qualified user id and a qualified - conversation id, update the conversation details locally with the other data - provided. This is used to alert remote backend of updates in the conversation - metadata of conversations in which at least one of their local users is involved. -* ``leave-conversation``: Given a remote user and a conversation id, remove the - the remote user from the (local) conversation. -* ``on-message-sent``: Given a remote message and a conversation id, propagate a message to local users. - This is used whenever there is a remote user in a conversation (see end-to-end flows). -* ``send-message``: Given a sender and a raw message request, send a message to - a conversation owned by another backend. This is used when the user sending a - message is not on the same backend as the conversation the message is sent in. - -See `the galley source code -`_ -for the current list of federated endpoints of the `Galley`, as well as their -precise inputs and outputs. - -.. _end-to-end-flows: - -End-to-End Flows ----------------- - -In the following end-to-end flows, we focus on the interaction between the Brigs -and Galleys of federated backends. While the interactions are facilitated by the -`Federator` and `Federation Ingress` components of the backends involved, which -handle the necessary discovery, authentication and authorization steps, we won't -mention these steps explicitly each time to keep the flows simple. - -Additionally we assume that the backend domain and the infra domain of the -respective backends involved are the same and each domain identifies a distinct -backend. - -.. _user-discovery: - -User Discovery -^^^^^^^^^^^^^^ - -In this flow, the user `A` at `backend-a.com` tries to search for user `B` at -`backend-b.com`. - -#. User `A@backend-a.com` enters the qualified user name of the target user - `B@backend-b.com` into the search field of their Wire client. -#. The client issues a query to ``/search/contacts`` of the Brig searching for - `B` at `backend-b.com`. -#. The Brig in `A`'s backend asks its local `Federator` to query the - ``search-users`` endpoint of B's backend for `B`. -#. `A`'s `Federator` queries `B`'s Brig via `B`'s `Federation Ingress` and - `Federator` as requested. -#. `B`'s Brig replies with `B`'s user name and qualified handle, the - response goes through `B`'s `Federator` and `Federation Ingress`, as well as - `A`'s `Federator` before it reaches `A`'s Brig. -#. `A`'s Brig forwards that information to `A`'s client. - -Conversation Establishment -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -After having discovered user `B` at `backend-b.com`, user `A` at `backend-a.com` -wants to establish a conversation with `B`. - -#. From the search results of a :ref:`user discovery` process, - `A` chooses to create a conversation with `B`. -#. `A`'s client issues a ``/users/backend-b.com/B/prekeys`` query to `A`'s - Brig. -#. `A`'s Brig asks its `Federator` to query the ``claim-prekey-bundle`` endpoint - of `B`'s backend using `B`'s user id. -#. `B`'s `Federation Ingress` forwards the query to the `Federator`, who in turn forwards it to - the local Brig. -#. `B`'s Brig replies with a prekey bundle for each of `B`'s clients, which is - forwarded to `A`'s Brig via `B`'s `Federator` and `Federation Ingress`, as well as `A`'s - `Federator`. -#. `A`'s Brig forwards that information to `A`'s client. -#. `A`'s client queries the ``/conversations`` endpoint of its Galley - using `B`'s user id. -#. `A`'s Galley creates the conversation locally and queries the - ``on-conversation-created`` endpoint of `B`'s Galley (again via its local - `Federator`, as well as `B`'s `Federation Ingress` and `Federator`) to inform it about the new - conversation, including the conversation metadata in the request. -#. `B`'s Galley registers the conversation locally and confirms the query. -#. `B`'s Galley notifies `B`'s client of the creation of the conversation. - -Message Sending (A) -^^^^^^^^^^^^^^^^^^^ - -Having established a conversation with user `B` at `backend-b.com`, user `A` at -`backend-a.com` wants to send a message to user `B`. - -#. In a conversation `conv-1@backend-a.com` on `A`'s backend with users - `A@backend-a.com` and `B@backend-b.com`, `A` sends a message by using the - ``/conversations/backend-a.com/conv-1/proteus/messages`` endpoint - on `A`'s Galley. -#. `A`'s Galley checks if `A` included all necessary user devices in their - request. For that it makes a ``get-user-clients`` request to `B`'s Galley. - `A`'s Galley checks that the returned list of clients matches the list of - clients the message was encrypted for. -#. `A`'s Galley sends the message to all clients in the conversation that are - part of `A`'s backend. -#. `A`'s Galley queries the ``on-message-sent`` endpoint on `B`'s Galley via its - `Federator` and `B`'s `Federation Ingress` and `Federator`. -#. `B`'s Galley will propagate the message to all local clients involved in the - conversation. - -Message Sending (B) -^^^^^^^^^^^^^^^^^^^ - -Having received a message from user `A` at `backend-a.com`, user `B` at -`backend-b.com` wants send a reply. - -#. In a conversation `conv-1@backend-a.com` on `A`'s backend with users - `A@backend-a.com` and `B@backend-b.com`, `B` sends a message by using the - ``/conversations/backend-a.com/conv-1/proteus/messages`` endpoint - on `B`'s backend. -#. `B`'s Galley queries the ``send-message`` endpoint on `A`'s backend. - *Steps 3-6 below are essentially the same as steps 2-5 in Message Sending (A)* -#. `A`'s Galley checks if `A` included all necessary user devices in their - request. For that it makes a ``get-user-clients`` request to `B`'s Galley. - `A`'s Galley checks that the returned list of clients matches the list of - clients the message was encrypted for. -#. `A`'s Galley sends the message to all clients in the conversation that are - part of `A`'s backend. -#. `A`'s Galley queries the ``on-message-sent`` endpoint on `B`'s Galley via its - `Federator` and `B`'s `Federation Ingress` and `Federator`. -#. `B`'s Galley will propagate the message to all local clients involved in the - conversation. diff --git a/docs/src/understand/federation/architecture.md b/docs/src/understand/federation/architecture.md new file mode 100644 index 0000000000..5d3c7ec762 --- /dev/null +++ b/docs/src/understand/federation/architecture.md @@ -0,0 +1,338 @@ +(architecture-and-network)= + +# Architecture and Network + +(federation-architecture)= + +## Architecture + +To facilitate connections between federated backends, two new components +are added to each backend: +{ref}`federation_ingress` and +{ref}`federator`. The +*Federation Ingress* is, as the name suggests the ingress point for +incoming connections from other backends, which are then forwarded to +the *Federator*. The *Federator* then further processes the requests. In +addition, the *Federator* also acts as *egress* point for requests from +internal backend components to other, remote backends. + +![image](img/federated-backend-architecture.png){width="100.0%"} + +(backend-domains)= + +### Backend domains + +Each backend has two domain strings: an *infrastructure domain* and a +*backend domain*. + +The *infrastructure domain* is the domain name under which the backend +is actually reachable via the network. It is also the domain name that +each backend uses in authenticating itself to other backends. + +Similarly, there is the *backend domain*, which is used to qualify the +names and identifiers of users local to an individual backend in the +context of federation. For example, a user with (unqualified) user name +*jane_doe* at a backend with backend domain *company-a.com* has the +qualified user name *jane_doe@company-a.com*, which is visible to users +of other backends in the context of federation. + +See +{ref}`qualified-identifiers-and-names` +The distinction between the two domains allows the owner of a (backend) +domain (e.g. *company-a.com*) to host their Wire backend under a +different (infra) domain (e.g. *wire.infra.company-a.com*). + +(backend-components)= + +### Backend components + +In addition to the regular components of a Wire backend, two additional +components are added to enable federation with other backends: The +*Federation Ingress* and the *Federator*. Other Wire components use +these two components to contact other backends and respond to queries +originating from remote backends. + +The following subsections briefly introduce the individual components, +their state and their functionality. The semantics of backend-to-backend +communication will be explained in more detail in the Section on +{ref}`federation-api` + +(federation_ingress)= + +#### Federation Ingress + +The *Federation Ingress* is a [kubernetes +ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) +and uses [nginx](https://nginx.org/en/) as its underlying software. + +It is configured with a set of X.509 certificates, which acts as root of +trust for the authentication of the infra domain of remote backends, as +well as with a certificate, which it uses to authenticate itself toward +other backends. + +Its functions are: + +- terminate TLS connections + - perform mutual {ref}`authentication` as + part of the TLS connection establishment +- forward requests to the local + {ref}`federator` instance, + along with the remote backend\'s client certificate + +(federator)= + +#### Federator + +The *Federator* performs additional authorization checks after receiving +federated requests from the *Federation Ingress* and acts as egress +point for other backend components. It can be configured to use an +{ref}`allow list +` to authorize incoming and +outgoing connections, and it keeps an X.509 client certificate for the +backend\'s infra domain to authenticate itself towards other backends. +Additionally, it requires a connection to a DNS resolver to +{ref}`discover` other backends. + +When receiving a request from an internal component, the *Federator* +will: + +1. If enabled, ensure the target domain is in the + {ref}`allow-list` +2. {ref}`discover ` the other + backend, +3. establish a + {ref}`mutually authenticated channel ` to the other backend using its client certificate, +4. send the request to the other backend and +5. forward the response back to the originating component (and + eventually to the originating Wire client). + +The *Federator* also implements the authorization logic for incoming +requests and acts as intermediary between the *Federation Ingress* and +the internal components. The *Federator* will, for incoming requests +from remote backends (forwarded via the local +{ref}`Federation Ingress `): + +1. {ref}`Discover ` the mapping + between backend domain claimed by the remote backend and its infra + domain, +2. verify that the discovered infra domain matches the domain in the + remote backend\'s client certificate, +3. if enabled, ensure that the backend domain of the other backend is + in the {ref}`allow list `, +4. forward requests to other wire-server components. + +(other-wire-server)= + +#### Other wire-server components + +Components such as \'brig\', \'galley\', or \'gundeck\' are responsible +for actual business logic and interfacing with databases and +non-federation related external services. See [source code +documentation](https://github.com/wireapp/wire-server). In the context +of federation, their functions include: + +- For incoming requests from other backends: + {ref}`per-request authorization` +- Outgoing requests to other backends are always sent via a local + {ref}`Federator` instance. + +For more information of the functionalities provided to remote backends +through their *Federator*, see the +{ref}`federated API documentation`. + +(backend-to-backend-communication)= + +## Backend to backend communication + +We require communication between the *Federator* of one (sending) +backend and the ingress of another (receiving) backend to be both +mutually authenticated and authorized. More specifically, both backends +need to ensure the following: + +- **Authentication** + + Determine the identity (infra domain name) of the other backend. + +- **Discovery** + + Ensure that the other backend is authorized to represent the backend + domain claimed by the other backend. + +- **Authorization** + + Ensure that this backend is authorized to federate with the other backend. + +(authentication)= + +### Authentication + +```{warning} +As of January 2022, the implementation of mutual backend-to-backend authentication is still subject to change. The behaviour described in this section should be considered a draft specification only. +``` + +Authentication between Wire backends is achieved using the mutual +authentication feature of TLS as defined in [RFC +8556](https://tools.ietf.org/html/rfc8446). + +In particular, this means that the ingress of each backend needs to be +provisioned with one or more certificates which the ingress trusts to +authenticate certificates provided by other backends when accepting +incoming connections. + +Conversely, every *Federator* needs to be provisioned with a (client) +certificate which it uses to authenticate itself towards other backends. + +Note that the client certificate is expected to be issued with the +backend\'s infra domain as one of the subject alternative names (SAN), +which is defined in [RFC 5280](https://tools.ietf.org/html/rfc5280). + +If a receiving backend fails to authenticate the client certificate, it should +reply with an *authentication error* (see {ref}`authentication-errors`) + +(discovery)= + +### Discovery + +The discovery process allows a backend to determine the infra domain of +a given backend domain. + +This step is necessary in two scenarios: + +- A backend would like to establish a connection to another backend + that it only knows the backend domain of. This is the case, for + example, when a user of a local backend searches for a + {ref}`qualified username `, which only includes that user\'s backend\'s backend + domain. +- When receiving a message from another backend that authenticates + with a given infra domain and claims to represent a given backend + domain, a backend would like to ensure the backend domain owner + authorized the owner of the infra domain to run their Wire backend. + +To make discovery possible, any party hosting a Wire backend has to +announce the infra domain via a DNS *SRV* record as defined in [RFC +2782](https://tools.ietf.org/html/rfc2782) with +`service = wire-server-federator, proto = tcp` and with `name` pointing +to the backend\'s domain and *target* to the backend\'s infra domain. + +For example, Company A with backend domain *company-a.com* and infra +domain *wire.company-a.com* could publish + +``` bash +_wire-server-federator._tcp.company-a.com. 600 IN SRV 10 5 443 federator.wire.company-a.com. +``` + +A backend can then be discovered, given its domain, by issuing a DNS +query for the SRV record specifying the *wire-server-federator* service. + +(dns-scope)= + +#### DNS Scope + +The network scope of the SRV record (as well as that of the DNS records +for backend and infra domain), depends on the desired federation +topology in the same way as other parameters such as the availability of +the CA certificate that allows authentication of the *Federation +Ingress*\' server certificate or the *Federator*\'s client certificate. +The general rule is that the SRV entry should be \"visible\" from the +point of view of the desired federation partners. The exact scope +strongly depends on the network architecture of the backends involved. + +(srv-ttl-and-caching)= + +#### SRV TTL and Caching + +After retrieving the SRV record for a given domain, the local backend +caches the *backend domain \<\--\> infra domain* mapping for the +duration indicated in the TTL field of the record. + +Due to this caching behaviour, the TTL value of the SRV record dictates +at which intervals remote backends will refresh their mapping of the +local backend\'s backend domain to infra domain. As a consequence a +value in the order of magnitude of 24 hours will reduce the amount of +overhead for remote backends. + +On the other hand in the setup phase of a backend, or when a change of +infra domain is required, a TTL value in the magnitude of a few minutes +allows remote backends to recover more quickly from a change of infra +domain. + +(authorization)= + +### Authorization + +After an incoming connection is authenticated, a second step is required +to ensure that the sending backend is authorized to connect to the +receiving backend. As the backend authenticates using its infra domain, +but the allow list contains backend domains (which is not necessarily +the same) the sending backend also needs to provide its backend domain. + +To make this possible, requests to remote backends are required to +contain a `Wire-Origin-Domain` header, which contains the remote +backend\'s domain. + +While the receiving backend has authenticated the sending backend as the +infra domain, it is not clear that the sending backend is indeed +authorized by the owner of the backend domain to host the Wire backend +of that particular domain. + +To perform this extra authorization step, the receiving backend follows +the process described in {ref}`discovery` and +checks that the discovered infra domain for the backend domain indicated +in the `Wire-Domain` header is one of the Subject Alternative Names +contained in the sending backend\'s client certificate. If this is not +the case, the receiving backend replies with a *discovery error* (see {ref}`authentication-errors`) + +Finally, the receiving backend checks if the domain of the sending +backend is in the {ref}`allow-list` and replies +with an `*authorization error*` (see {ref}`authentication-errors`) if it is not. + + +(allow-list)= + +#### Domain Allow List + +Federation can happen between any backends on a network (e.g. the open +internet); or it can be restricted via server configuration to happen +between a specified set of domains on an \'allow list\'. If an allow +list is configured, then: + +- outgoing requests will only happen if the requested domain is + contained in the allow list. +- incoming requests: if the domain of the sending backend is not in + the allow list, any request originating from that domain is replied + to with an + `authorization error `{.interpreted-text + role="ref"} + +(per-request-authorization)= + +#### Per-request authorization + +In addition to the general authorization step that is performed by the +federator when a new, mutually authenticated TLS connection is +established, the component processing the request performs an +additional, per-request authorization step. + +How this step is performed depends on the API endpoint, the contents of +the request and the context in which it is made. + +See the documentation of the individual {ref}`API endpoints ` for +details. + +(example)= + +### Example + +The following is an example for the message and information flow between +a backend with backend domain `a.com` and infra domain `infra.a.com` and +another backend with backend domain `b.com` and infra domain +`infra.b.com`. + +The content and format of the message is meant to be representative. For +the definitions of the actual payloads, please see the {ref}`federation +API` section. + +The scenario is that the brig at `infra.a.com` has received a user +search request from *Alice*, one of its clients. + +![image](img/federation-flow.png) diff --git a/docs/src/understand/federation/architecture.rst b/docs/src/understand/federation/architecture.rst deleted file mode 100644 index c3d252011f..0000000000 --- a/docs/src/understand/federation/architecture.rst +++ /dev/null @@ -1,326 +0,0 @@ -Architecture and Network -========================= - -.. _federation-architecture: - -Architecture -------------- - -To facilitate connections between federated backends, two new components are -added to each backend: :ref:`Federation Ingress ` and -:ref:`Federator `. The `Federation Ingress` is, as the name suggests -the ingress point for incoming connections from other backends, which are then -forwarded to the `Federator`. The `Federator` then further processes the -requests. In addition, the `Federator` also acts as *egress* point for requests -from internal backend components to other, remote backends. - -.. image:: img/federated-backend-architecture.png - :width: 100% - -.. _backend-domains: - -Backend domains -^^^^^^^^^^^^^^^ - -Each backend has two domain strings: an `infrastructure domain` and a -`backend domain`. - -The `infrastructure domain` is the domain name under which the backend is -actually reachable via the network. It is also the domain name that each -backend uses in authenticating itself to other backends. - -Similarly, there is the `backend domain`, which is used to qualify the names and -identifiers of users local to an individual backend in the context of -federation. For example, a user with (unqualified) user name `jane_doe` at a -backend with backend domain `company-a.com` has the qualified user name -`jane_doe@company-a.com`, which is visible to users of other backends in the -context of federation. - -See :ref:`Qualified Identifiers and Names ` for -more information on qualified names and identifiers. - -The distinction between the two domains allows the owner of a (backend) domain -(e.g. `company-a.com`) to host their Wire backend under a different (infra) -domain (e.g. `wire.infra.company-a.com`). - - -Backend components -^^^^^^^^^^^^^^^^^^ - -In addition to the regular components of a Wire backend, two additional -components are added to enable federation with other backends: The `Federation -Ingress` and the `Federator`. Other Wire components use these two components to -contact other backends and respond to queries originating from remote backends. - -The following subsections briefly introduce the individual components, their -state and their functionality. The semantics of backend-to-backend communication -will be explained in more detail in the Section on :ref:`Federation API -`. - -.. _federation_ingress: - -Federation Ingress -~~~~~~~~~~~~~~~~~~ - -The `Federation Ingress` is a `kubernetes ingress -`_ and uses -`nginx `_ as its underlying software. - -It is configured with a set of X.509 certificates, which acts as root of trust -for the authentication of the infra domain of remote backends, as well as with a -certificate, which it uses to authenticate itself toward other backends. - -Its functions are: - -* terminate TLS connections - - - perform mutual :ref:`authentication` as part of the TLS connection - establishment - -* forward requests to the local :ref:`Federator ` instance, along - with the remote backend's client certificate - - -.. _federator: - -Federator -~~~~~~~~~ - -The `Federator` performs additional authorization checks after receiving -federated requests from the `Federation Ingress` and acts as egress point for -other backend components. It can be configured to use an :ref:`allow list -` to authorize incoming and outgoing connections, and it keeps an -X.509 client certificate for the backend's infra domain to authenticate itself -towards other backends. Additionally, it requires a connection to a DNS resolver -to :ref:`discover` other backends. - -When receiving a request from an internal component, the `Federator` will: - -#. If enabled, ensure the target domain is in the :ref:`allow list ` -#. :ref:`discover ` the other backend, -#. establish a :ref:`mutually authenticated channel ` to the - other backend using its client certificate, -#. send the request to the other backend and -#. forward the response back to the originating component (and eventually to the - originating Wire client). - -The `Federator` also implements the authorization logic for incoming requests and -acts as intermediary between the `Federation Ingress` and the internal -components. The `Federator` will, for incoming requests from remote backends -(forwarded via the local :ref:`Federation Ingress `): - -#. :ref:`Discover ` the mapping between backend domain claimed by the - remote backend and its infra domain, -#. verify that the discovered infra domain matches the domain in the remote - backend's client certificate, -#. if enabled, ensure that the backend domain of the other backend is in the - :ref:`allow list `, -#. forward requests to other wire-server components. - -.. _other-wire-server: - -Other wire-server components -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Components such as 'brig', 'galley', or 'gundeck' are responsible for actual -business logic and interfacing with databases and non-federation related -external services. See `source code documentation -`_. In the context of federation, their -functions include: - -* For incoming requests from other backends: :ref:`per-request authorization` -* Outgoing requests to other backends are always sent via a local :ref:`Federator` instance. - -For more information of the functionalities provided to remote backends through -their `Federator`, see the :ref:`federated API documentation`. - -.. _backend-to-backend-communication: - -Backend to backend communication --------------------------------------------- - -We require communication between the `Federator` of one (sending) backend and -the ingress of another (receiving) backend to be both mutually authenticated and -authorized. More specifically, both backends need to ensure the following: - -:Authentication: Determine the identity (infra domain name) of the other - backend. -:Discovery: Ensure that the other backend is authorized to represent the backend - domain claimed by the other backend. -:Authorization: Ensure that this backend is authorized to federate with the - other backend. - - -.. _authentication: - -Authentication -^^^^^^^^^^^^^^ - -.. warning:: As of January 2022, the implementation of mutual backend-to-backend - authentication is still subject to change. The behaviour described - in this section should be considered a draft specification only. - -Authentication between Wire backends is achieved using the mutual authentication -feature of TLS as defined in `RFC 8556 `_. - -In particular, this means that the ingress of each backend needs to be -provisioned with one or more certificates which the ingress trusts to -authenticate certificates provided by other backends when accepting incoming -connections. - -Conversely, every `Federator` needs to be provisioned with a (client) -certificate which it uses to authenticate itself towards other backends. - -Note that the client certificate is expected to be issued with the backend's -infra domain as one of the subject alternative names (SAN), which is defined in -`RFC 5280 `_. - -If a receiving backend fails to authenticate the client certificate, it should -reply with an :ref:`authentication error `. - - -.. _discovery: - -Discovery -^^^^^^^^^ - -The discovery process allows a backend to determine the infra domain of a given -backend domain. - -This step is necessary in two scenarios: - -* A backend would like to establish a connection to another backend that it only - knows the backend domain of. This is the case, for example, when a user of a - local backend searches for a :ref:`qualified username `, - which only includes that user's backend's backend domain. -* When receiving a message from another backend that authenticates with a given - infra domain and claims to represent a given backend domain, a backend would - like to ensure the backend domain owner authorized the owner of the infra - domain to run their Wire backend. - -To make discovery possible, any party hosting a Wire backend has to announce the -infra domain via a DNS `SRV` record as defined in `RFC 2782 -`_ with `service = wire-server-federator, -proto = tcp` and with `name` pointing to the backend's domain and `target` to -the backend's infra domain. - -For example, Company A with backend domain `company-a.com` and infra domain -`wire.company-a.com` could publish - -.. code-block:: bash - - _wire-server-federator._tcp.company-a.com. 600 IN SRV 10 5 443 federator.wire.company-a.com. - -A backend can then be discovered, given its domain, by issuing a DNS query for -the SRV record specifying the `wire-server-federator` service. - -DNS Scope -~~~~~~~~~ - -The network scope of the SRV record (as well as that of the DNS records for -backend and infra domain), depends on the desired federation topology in the -same way as other parameters such as the availability of the CA certificate that -allows authentication of the `Federation Ingress`' server certificate or the -`Federator`'s client certificate. The general rule is that the SRV entry should -be "visible" from the point of view of the desired federation partners. The -exact scope strongly depends on the network architecture of the backends -involved. - -SRV TTL and Caching -~~~~~~~~~~~~~~~~~~~ - -After retrieving the SRV record for a given domain, the local backend caches the -`backend domain <--> infra domain` mapping for the duration indicated in the TTL -field of the record. - -Due to this caching behaviour, the TTL value of the SRV record dictates at which -intervals remote backends will refresh their mapping of the local backend's -backend domain to infra domain. As a consequence a value in the order of -magnitude of 24 hours will reduce the amount of overhead for remote backends. - -On the other hand in the setup phase of a backend, or when a change of infra -domain is required, a TTL value in the magnitude of a few minutes allows remote -backends to recover more quickly from a change of infra domain. - -.. _authorization: - -Authorization -^^^^^^^^^^^^^ - -After an incoming connection is authenticated, a second step is required to -ensure that the sending backend is authorized to connect to the receiving -backend. As the backend authenticates using its infra domain, but the allow list -contains backend domains (which is not necessarily the same) the sending backend -also needs to provide its backend domain. - -To make this possible, requests to remote backends are required to contain a -`Wire-Origin-Domain` header, which contains the remote backend's domain. - -While the receiving backend has authenticated the sending backend as the infra -domain, it is not clear that the sending backend is indeed authorized by the -owner of the backend domain to host the Wire backend of that particular domain. - -To perform this extra authorization step, the receiving backend follows the -process described in :ref:`discovery` and checks that the discovered infra -domain for the backend domain indicated in the `Wire-Domain` header is one of -the Subject Alternative Names contained in the sending backend's client -certificate. If this is not the case, the receiving backend replies with a -:ref:`discovery error `. - -Finally, the receiving backend checks if the domain of the sending backend is in -the :ref:`allow-list` and replies with an :ref:`authorization error -` if it is not. - -.. _allow-list: - -Domain Allow List -~~~~~~~~~~~~~~~~~ - -Federation can happen between any backends on a network (e.g. the open -internet); or it can be restricted via server configuration to happen between a -specified set of domains on an 'allow list'. If an allow list is configured, -then: - -* outgoing requests will only happen if the requested domain is contained in the allow list. -* incoming requests: if the domain of the sending backend is not in the allow - list, any request originating from that domain is replied to with an - :ref:`authorization error ` - - -.. _per-request-authorization: - -Per-request authorization -~~~~~~~~~~~~~~~~~~~~~~~~~ - -In addition to the general authorization step that is performed by the federator -when a new, mutually authenticated TLS connection is established, the component -processing the request performs an additional, per-request authorization step. - -How this step is performed depends on the API endpoint, the contents of the -request and the context in which it is made. - -See the documentation of the individual :ref:`API endpoints ` for -details. - - -Example -^^^^^^^ - -The following is an example for the message and information flow between a -backend with backend domain `a.com` and infra domain `infra.a.com` and another -backend with backend domain `b.com` and infra domain `infra.b.com`. - -The content and format of the message is meant to be representative. For the -definitions of the actual payloads, please see the :ref:`federation -API` section. - -The scenario is that the brig at `infra.a.com` has received a user search -request from `Alice`, one of its clients. - -.. image:: img/federation-flow.png - :width: 100% - - - -.. - paths to images are currently listed at the end of the file. If you prefer to specify them directly in the paragraph they are used, that is also fine. diff --git a/docs/src/understand/federation/errors.rst b/docs/src/understand/federation/errors.rst deleted file mode 100644 index 9a44798e58..0000000000 --- a/docs/src/understand/federation/errors.rst +++ /dev/null @@ -1,23 +0,0 @@ -Error Codes -========================= - -This page describes the errors that can occur during federation. - -.. _authentication-errors: - -Authentication Errors ---------------------- - -TODO for now, we only describe the errors here. Later, we should add exact error codes. - -TODO we might want to merge one or more of these errors - -* _`authentication error`: occurs when a backend queries another backend and - provides either no client certificate, or a client certificate that the - receiving backend cannot authenticate -* _`authorization error`: occurs when a sending backend authenticates successfully, - but is not on the allow list of the receiving backend -* _`discovery error`: occurs when a sending backend authenticates - successfully, but the `SRV` record published for the claimed domain of the - sending backend doesn't match the SAN of the sending backend's client - certificate diff --git a/docs/src/understand/federation/faq.rst b/docs/src/understand/federation/faq.rst deleted file mode 100644 index 26420e4417..0000000000 --- a/docs/src/understand/federation/faq.rst +++ /dev/null @@ -1,4 +0,0 @@ -.. federation-faq: - -Federation FAQ -=============== diff --git a/docs/src/understand/federation/glossary.md b/docs/src/understand/federation/glossary.md new file mode 100644 index 0000000000..cfb0cdea6f --- /dev/null +++ b/docs/src/understand/federation/glossary.md @@ -0,0 +1,108 @@ +(glossary)= + +# Federation Glossary + +(glossary_backend)= +## Backend +> A set of servers, databases and DNS configurations together forming one single +> Wire Server entity as seen from outside. This set of servers can be owned and +> administrated by different legal entities in different countries. Sometimes +> also called a Wire \"instance\" or \"server\" or \"Wire installation\". Every +> resource (e.g. users, conversations, assets and teams) exists and is owned by +> one specific backend, which we can refer to as that resource\'s backend + +(glossary_backend_domain)= +## Backend Domain + +> The domain of a backend, which is used to qualify the names and +> identifiers of resources (users, clients, groups, etc) that are local +> to a given backend. See also +> {ref}`consequences-backend-domain` + +(glossary_infra_domain)= + +## Infrastructure Domain or Infra Domain + +> The domain under which the +> `Federator `{.interpreted-text role="ref"} of a +> given backend is reachable (via that backend\'s +> `Ingress `{.interpreted-text role="ref"}) +> for other, remote backends. + +(glossary_federation_ingress)= + +## Federation Ingress + +> Federation Ingress is the first point of contact of a given `backend +> `{.interpreted-text role="ref"} for other, remote +> backends. It also deals with the `authentication`{.interpreted-text +> role="ref"} of incoming requests. See +> `here `{.interpreted-text role="ref"} for more +> information. + +(glossary_federator)= + +## Federator + +> The [Federator]{.title-ref} is the local point of contact for +> `other backend +> components `{.interpreted-text role="ref"} that +> want to make calls to remote backends. It is also the component that +> deals with the `authorization`{.interpreted-text role="ref"} of +> incoming requests from other backends after they have passed the +> `Federation Ingress +> `{.interpreted-text role="ref"}. See +> `here `{.interpreted-text role="ref"} for more information. + +(glossary_asset)= +## Asset + +> Any file or image sent via Wire (uploaded to and downloaded from a +> backend). + +(glossary_qualified-user-id)= +## Qualified User Identifier (QUID) + +> A combination of a UUID (unique on the user\'s backend) and a domain. + +(glossary_qualified-user-name)= +## Qualified User Name (QUN) + +> A combination of a name that is unique on the user\'s backend and a +> domain. The name is a string consisting of 2-256 characters which are +> either lower case alphanumeric, dashes, underscores or dots. See +> [here](https://github.com/wireapp/wire-server/blob/f683299a03207acb505254ff3121213383d0b672/libs/types-common/src/Data/Handle.hs#L76-L93) +> for the code defining the rules for user names. Note that in the +> wire-server source code, user names are called \'Handle\' and +> qualified user names \'Qualified Handle\'. + +(glossary_qualified-client-id)= +## Qualified Client Identifier (QDID) + +> A combination of a client identifier (a hash of the public key +> generated for a user\'s client) concatenated with a dot and the QUID +> of the associated user. + +(glossary_qualified-group-id)= +## Qualified Group Identifier (QGID) + +> The string [backend-domain.com/groups/]{.title-ref} concatenated with +> a UUID that is unique on a given backend. + +(glossary_qualified-conversation-id)= +## Qualified Conversation Identifier (QCID) + +> The same as a `QGID `{.interpreted-text +> role="ref"}. + +(glossary_qualified-team-id)= +## Qualified Team Identifier (QTID) + +> The string [backend-domain.com/teams/]{.title-ref} concatenated with a +> UUID that is unique on a given backend. + +(glossary_display-name)= +## (User) Profile/Display Name + +> The profile/display name of a user is a UTF-8 encoded string with +> 1-128 characters. diff --git a/docs/src/understand/federation/glossary.rst b/docs/src/understand/federation/glossary.rst deleted file mode 100644 index db099a1ecf..0000000000 --- a/docs/src/understand/federation/glossary.rst +++ /dev/null @@ -1,111 +0,0 @@ -.. _glossary: - -Federation Glossary -===================== - - -.. - note to documentation authors: - until https://github.com/rst2pdf/rst2pdf/issues/898 is fixed we should not use the glossary:: directive and not refer to items with the :term:`text to appear ` syntax. Instead, we can use explicit section labels and refer to them with :ref:`text to appear ` - -.. _glossary_backend: - -Backend - - A set of servers, databases and DNS configurations together forming one single Wire Server entity as seen from outside. This set of servers can be owned and administrated by different legal entities in different countries. - - Sometimes also called a Wire "instance" or "server" or "Wire installation". - Every resource (e.g. users, conversations, assets and teams) exists and is owned by one specific backend, which we can refer to as that resource's backend - -.. _glossary_backend_domain: - -Backend Domain - - The domain of a backend, which is used to qualify the names and identifiers of - resources (users, clients, groups, etc) that are local to a given backend. - See also the :ref:`Consequences of choosing a backend domain ` - -.. _glossary_infra_domain: - -Infrastructure Domain or Infra Domain - - The domain under which the :ref:`Federator ` of a given - backend is reachable (via that backend's :ref:`Ingress `) - for other, remote backends. - -.. _glossary_federation_ingress: - -Federation Ingress - - Federation Ingress is the first point of contact of a given :ref:`backend - ` for other, remote backends. It also deals with the - :ref:`authentication` of incoming requests. See :ref:`here ` for - more information. - -.. _glossary_federator: - -Federator - - The `Federator` is the local point of contact for :ref:`other backend - components ` that want to make calls to remote backends. - It is also the component that deals with the :ref:`authorization` of incoming - requests from other backends after they have passed the :ref:`Federation Ingress - `. See :ref:`here ` for more information. - -.. _glossary_asset: - -Asset - - Any file or image sent via Wire (uploaded to and downloaded from a backend). - -.. _glossary_qualified-user-id: - -Qualified User Identifier (QUID) - - A combination of a UUID (unique on the user's backend) and a domain. - -.. _glossary_qualified-user-name: - -Qualified User Name (QUN) - - A combination of a name that is unique on the user's backend and a domain. The - name is a string consisting of 2-256 characters which are either lower case - alphanumeric, dashes, underscores or dots. See `here - `_ - for the code defining the rules for user names. Note that in the wire-server - source code, user names are called 'Handle' and qualified user names - 'Qualified Handle'. - -.. _glossary_qualified-client-id: - -Qualified Client Identifier (QDID) - - A combination of a client identifier (a hash of the public key generated for a - user's client) concatenated with a dot and the QUID of the associated user. - -.. _glossary_qualified-group-id: - -Qualified Group Identifier (QGID) - - The string `backend-domain.com/groups/` concatenated with a UUID that is - unique on a given backend. - -.. _glossary_qualified-conversation-id: - -Qualified Conversation Identifier (QCID) - - The same as a :ref:`QGID `. - -.. _glossary_qualified-team-id: - -Qualified Team Identifier (QTID) - - The string `backend-domain.com/teams/` concatenated with a UUID that is - unique on a given backend. - -.. _glossary_display-name: - -(User) Profile/Display Name - - The profile/display name of a user is a UTF-8 encoded string with 1-128 - characters. diff --git a/docs/src/understand/federation/index.rst b/docs/src/understand/federation/index.rst index ed25458ed3..70ff484f1f 100644 --- a/docs/src/understand/federation/index.rst +++ b/docs/src/understand/federation/index.rst @@ -1,7 +1,7 @@ .. _federation-understand: +++++++++++++++++ -Wire federation +Wire Federation +++++++++++++++++ Wire Federation, once implemented, aims to allow multiple Wire-server :ref:`backends ` to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. @@ -22,5 +22,4 @@ Wire Federation, once implemented, aims to allow multiple Wire-server :ref:`back introduction architecture - *roadmap * diff --git a/docs/src/understand/federation/introduction.md b/docs/src/understand/federation/introduction.md new file mode 100644 index 0000000000..02e4d057a8 --- /dev/null +++ b/docs/src/understand/federation/introduction.md @@ -0,0 +1,45 @@ +(introduction)= + +# Introduction + +Federation is a feature that allows a collection of Wire backends to +enable the establishment of connections among their respective users. + +(goals)= + +## Goals + +If two Wire backends A and B are *federated*, the goal is for users of +backend A to be able to communicate with users of backend B and +vice-versa in the same way as if they were both part of the same +backend. + +Federated backends should be able to identify, discover and authenticate +one-another using the domain names under which they are reachable via +the network. + +To enable federation, administrators of a Wire backend can decide to +either specifically list the backends that they want to federate with, +or to allow federation with all Wire backends reachable from the +network. + +Federation is facilitated by two backend components: the *Federation +Ingress*, which, as the name suggests, acts as ingress point for +federated traffic and the *Federator*, which acts as egress point and +processes all ingress requests from the Federation Ingress after the +authentication step. + +(non-goals)= + +## Non-Goals + +We aim to integrate federation into the Wire backend following a +step-by-step process as described in the +{ref}`federation-roadmap`. +Early versions are not meant to enable a completely open federation, but +rather a closed network of federated backends with a restricted set of +features. + +The aim of federation is not to replace the existing organizational +structures for Wire users such as teams and groups, but rather to +complement them. diff --git a/docs/src/understand/federation/introduction.rst b/docs/src/understand/federation/introduction.rst deleted file mode 100644 index cb136ace9e..0000000000 --- a/docs/src/understand/federation/introduction.rst +++ /dev/null @@ -1,35 +0,0 @@ -Introduction -============ - -Federation is a feature that allows a collection of Wire backends to enable the -establishment of connections among their respective users. - -Goals ------ - -If two Wire backends A and B are *federated*, the goal is for users of backend A -to be able to communicate with users of backend B and vice-versa in the same way -as if they were both part of the same backend. - -Federated backends should be able to identify, discover and authenticate -one-another using the domain names under which they are reachable via the -network. - -To enable federation, administrators of a Wire backend can decide to either -specifically list the backends that they want to federate with, or to allow federation with all Wire backends reachable from the network. - -Federation is facilitated by two backend components: the *Federation Ingress*, -which, as the name suggests, acts as ingress point for federated traffic and the -*Federator*, which acts as egress point and processes all ingress requests from -the Federation Ingress after the authentication step. - -Non-Goals ---------- - -We aim to integrate federation into the Wire backend following a step-by-step -process as described in the :ref:`federation roadmap`. Early -versions are not meant to enable a completely open federation, but rather a -closed network of federated backends with a restricted set of features. - -The aim of federation is not to replace the existing organizational structures -for Wire users such as teams and groups, but rather to complement them. diff --git a/docs/src/understand/federation/replace.sh b/docs/src/understand/federation/replace.sh new file mode 100644 index 0000000000..7b4da2997f --- /dev/null +++ b/docs/src/understand/federation/replace.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh + +set -x + +for f in *.rst; do + if [ "$f" = "index.rst" ]; then + continue; + fi + pandoc -f rst -t commonmark_x < "$f" > "${f%.rst}.md" + rm "$f" +done diff --git a/docs/src/understand/federation/roadmap.md b/docs/src/understand/federation/roadmap.md new file mode 100644 index 0000000000..87fb85834a --- /dev/null +++ b/docs/src/understand/federation/roadmap.md @@ -0,0 +1,112 @@ +(federation-roadmap)= + +# Implementation Roadmap + +Internally at Wire, we have divided implemention of federation into +multiple milestones. Only the milestone on which implementation has +started will be shown here (as later milestones are subject to internal +change and re-ordering) + +(m1-federation-with-proteus-mvp)= + +## M1 federation with proteus MVP + +The first milestone **M1** is a minimum-viable-product that allows users +on different Wire backends to send textual messages to users on other +backends. + +M1 included support for: + +- user search +- creating group conversations +- message sending +- visual UX for showing federation. +- a way for on-premise (self-hosted) installations of wire to try out + this implementation of federation by explicitly enabling it via + configuration flags. +- Android, Web and iOS will be supported +- server2server discovery and authentication +- a way to specify an allow list of backends to federate with + +(m2-federation-with-callingconferencing-and-assets)= + +## M2 federation with calling/conferencing and assets + +The second milestone **M2** focused on: + +- federated calling +- federated conferencing +- basic federated asset support. + +**M2** also incorporated a previous interim release which added the +following in a federated environment: + +- likes +- mentions +- read receipts and delivery notifications +- pings +- edit and delete messages + +Caveats: + +- Message delivery guarantees are weak if any backends are temporarily + unavailable. +- If any backends are unavailable, data inconsistencies may occur. +- Federation with the production cloud version of wire.com is not yet + supported. +- Federated conferencing requires an SFT in each domain represented in + the conversation. The caller\'s SFT is the \"anchor\" SFT, to which + federated SFTs connect: + - SFTs must have valid certificates suitable for mutual + authentication with federated SFTs. + - Currently all video streams are exchanged between the anchor SFT + and each federated SFT. The SFTs select the relevant streams for + each client as today, but inter-SFT traffic could use + substantially more bandwidth than an SFT to client stream. + - The administrator needs to open ports between their SFTs and + federated SFTs for signalling and media. +- Assets will be stored on the backend of the sender and fetched via + the sender\'s backend with every access (there is no caching on a + federated domain). If federated domains have different policies for + allowed asset types or sizes, a user may receive notification of an + asset which it is not allowed to fetch or view. + + +```{note} +A rough (Backend) Implementation Status as of January 2022: + +Tested in M2 scope: + +- Federator as Egress, and Ingress support to allow + backend-backend communication +- Long-running test environments +- Backend Discovery via SRV records +- Backend allow list support +- User search via exact handle +- Get user profile, user clients, and prekeys for their clients +- Create conversation with remote users +- Send a message in a conversation with remote users +- Server2server authentication +- connections +- Assets +- Calling +- Conferencing + +Partially done: + +- client-server API changes for federation +- Other conversation features (removing users, archived/muted, + \...) +``` + +(additional-milestones)= + +## Additional Milestones + +Some additional milestones planned include the following features: + +- support more features (guest users, bots, \...) +- support better message delivery guarantees +- federation API versioning strategy +- support for wire-server installations to federate with wire.com +- MLS support diff --git a/docs/src/understand/federation/roadmap.rst b/docs/src/understand/federation/roadmap.rst deleted file mode 100644 index 03c427a6b2..0000000000 --- a/docs/src/understand/federation/roadmap.rst +++ /dev/null @@ -1,85 +0,0 @@ -.. _federation-roadmap: - -Implementation Roadmap -======================= - -Internally at Wire, we have divided implemention of federation into multiple milestones. Only the milestone on which implementation has started will be shown here (as later milestones are subject to internal change and re-ordering) - -M1 federation with proteus MVP ------------------------------- - -The first milestone **M1** is a minimum-viable-product that allows users on different Wire backends to send textual messages to users on other backends. - -M1 included support for: - -* user search -* creating group conversations -* message sending -* visual UX for showing federation. -* a way for on-premise (self-hosted) installations of wire to try out this implementation of federation by explicitly enabling it via configuration flags. -* Android, Web and iOS will be supported -* server2server discovery and authentication -* a way to specify an allow list of backends to federate with - - -M2 federation with calling/conferencing and assets --------------------------------------------------- - -The second milestone **M2** focused on: - -* federated calling -* federated conferencing -* basic federated asset support. - -**M2** also incorporated a previous interim release which added the following in a federated environment: - -* likes -* mentions -* read receipts and delivery notifications -* pings -* edit and delete messages - -Caveats: - -* Message delivery guarantees are weak if any backends are temporarily unavailable. -* If any backends are unavailable, data inconsistencies may occur. -* Federation with the production cloud version of wire.com is not yet supported. -* Federated conferencing requires an SFT in each domain represented in the conversation. The caller's SFT is the "anchor" SFT, to which federated SFTs connect: - - * SFTs must have valid certificates suitable for mutual authentication with federated SFTs. - * Currently all video streams are exchanged between the anchor SFT and each federated SFT. The SFTs select the relevant streams for each client as today, but inter-SFT traffic could use substantially more bandwidth than an SFT to client stream. - * The administrator needs to open ports between their SFTs and federated SFTs for signalling and media. -* Assets will be stored on the backend of the sender and fetched via the sender's backend with every access (there is no caching on a federated domain). If federated domains have different policies for allowed asset types or sizes, a user may receive notification of an asset which it is not allowed to fetch or view. - -.. note:: - A rough (Backend) Implementation Status as of January 2022: - - Tested in M2 scope: - * Federator as Egress, and Ingress support to allow backend-backend communication - * Long-running test environments - * Backend Discovery via SRV records - * Backend allow list support - * User search via exact handle - * Get user profile, user clients, and prekeys for their clients - * Create conversation with remote users - * Send a message in a conversation with remote users - * Server2server authentication - * connections - * Assets - * Calling - * Conferencing - - Partially done: - * client-server API changes for federation - * Other conversation features (removing users, archived/muted, ...) - -Additional Milestones ---------------------- - -Some additional milestones planned include the following features: - -* support more features (guest users, bots, ...) -* support better message delivery guarantees -* federation API versioning strategy -* support for wire-server installations to federate with wire.com -* MLS support From e7994b0054c979697aaaa0e5165e7a80fdbd23a2 Mon Sep 17 00:00:00 2001 From: Molly Miller <33266253+sysvinit@users.noreply.github.com> Date: Mon, 9 Jan 2023 14:30:05 +0100 Subject: [PATCH 13/33] Add TLS cipher configuration to coturn Helm chart [SQPIT-1512] (#2924) * charts/coturn: add TLS cipher configuration, comply with BSI TR-02102-2 by default. * changelog: update. --- changelog.d/2-features/coturn-tls | 4 ++++ charts/coturn/templates/configmap-coturn-conf-template.yaml | 3 +++ charts/coturn/values.yaml | 2 ++ 3 files changed, 9 insertions(+) create mode 100644 changelog.d/2-features/coturn-tls diff --git a/changelog.d/2-features/coturn-tls b/changelog.d/2-features/coturn-tls new file mode 100644 index 0000000000..f193fb9c19 --- /dev/null +++ b/changelog.d/2-features/coturn-tls @@ -0,0 +1,4 @@ +The coturn Helm chart now has a `.tls.ciphers` option to allow setting +the cipher list for TLS connections, when TLS is enabled. By default, +this option is set to a cipher list which is compliant with [BSI +TR-02102-2](https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.pdf). diff --git a/charts/coturn/templates/configmap-coturn-conf-template.yaml b/charts/coturn/templates/configmap-coturn-conf-template.yaml index 4a2a4c4c06..b981c3cce9 100644 --- a/charts/coturn/templates/configmap-coturn-conf-template.yaml +++ b/charts/coturn/templates/configmap-coturn-conf-template.yaml @@ -13,6 +13,9 @@ data: {{- if .Values.tls.enabled }} cert=/secrets-tls/tls.crt pkey=/secrets-tls/tls.key + {{- if .Values.tls.ciphers }} + cipher-list={{ .Values.tls.ciphers }} + {{- end }} {{- else }} no-tls {{- end }} diff --git a/charts/coturn/values.yaml b/charts/coturn/values.yaml index eede1626be..d56986c6e0 100644 --- a/charts/coturn/values.yaml +++ b/charts/coturn/values.yaml @@ -28,6 +28,8 @@ coturnTurnTlsListenPort: 5349 tls: enabled: false + # compliant with BSI TR-02102-2 + ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384' secretRef: reloaderImage: # container image containing https://github.com/Pluies/config-reloader-sidecar From 43a6a3d7cd84f2789bbe783faad43d3a6d6162a0 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 9 Jan 2023 16:48:28 +0100 Subject: [PATCH 14/33] [SQSERVICES-1647] Servantify proxy (the swagger part) (#2848) Co-authored-by: Leif Battermann --- changelog.d/5-internal/pr-2848 | 1 + libs/extended/default.nix | 2 + libs/extended/extended.cabal | 2 + .../extended/src/Servant/API/Extended/RawM.hs | 58 +++++++++++++ .../src/Wire/API/Routes/Public/Proxy.hs | 62 ++++++++++++++ libs/wire-api/wire-api.cabal | 1 + services/brig/src/Brig/API/Public.hs | 2 + services/proxy/src/Proxy/API.hs | 4 + services/proxy/src/Proxy/API/Public.hs | 4 + services/proxy/test/scripts/.gitignore | 1 + services/proxy/test/scripts/proxy-test.sh | 85 +++++++++++++++++++ 11 files changed, 222 insertions(+) create mode 100644 changelog.d/5-internal/pr-2848 create mode 100644 libs/extended/src/Servant/API/Extended/RawM.hs create mode 100644 libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs create mode 100644 services/proxy/test/scripts/.gitignore create mode 100755 services/proxy/test/scripts/proxy-test.sh diff --git a/changelog.d/5-internal/pr-2848 b/changelog.d/5-internal/pr-2848 new file mode 100644 index 0000000000..44c201f7cd --- /dev/null +++ b/changelog.d/5-internal/pr-2848 @@ -0,0 +1 @@ +Give proxy service a servant routing table for swagger (not for replacing wai-route; see comments in source code) diff --git a/libs/extended/default.nix b/libs/extended/default.nix index d51ab0466c..86c634508d 100644 --- a/libs/extended/default.nix +++ b/libs/extended/default.nix @@ -19,6 +19,7 @@ , lib , metrics-wai , optparse-applicative +, resourcet , servant , servant-server , servant-swagger @@ -44,6 +45,7 @@ mkDerivation { imports metrics-wai optparse-applicative + resourcet servant servant-server servant-swagger diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal index 9d64c15b6c..b58883b2c1 100644 --- a/libs/extended/extended.cabal +++ b/libs/extended/extended.cabal @@ -20,6 +20,7 @@ library exposed-modules: Options.Applicative.Extended Servant.API.Extended + Servant.API.Extended.RawM System.Logger.Extended other-modules: Paths_extended @@ -81,6 +82,7 @@ library , imports , metrics-wai , optparse-applicative + , resourcet , servant , servant-server , servant-swagger diff --git a/libs/extended/src/Servant/API/Extended/RawM.hs b/libs/extended/src/Servant/API/Extended/RawM.hs new file mode 100644 index 0000000000..9f1e1a6395 --- /dev/null +++ b/libs/extended/src/Servant/API/Extended/RawM.hs @@ -0,0 +1,58 @@ +-- | copy of https://github.com/haskell-servant/servant/pull/1551 while we're waiting for this +-- to be released. this was needed in https://github.com/wireapp/wire-server/pull/2848/, but +-- then in the end it wasn't. we keep it here in the hope that whoever needs it next will +-- have an easier time putting it to work. +module Servant.API.Extended.RawM where + +import Control.Monad.Trans.Resource +import Data.Metrics.Servant +import Data.Proxy +import Imports +import Network.Wai +import Servant.API (Raw) +import Servant.Server hiding (respond) +import Servant.Server.Internal.Delayed +import Servant.Server.Internal.RouteResult +import Servant.Server.Internal.Router +import Servant.Swagger + +type ApplicationM m = Request -> (Response -> IO ResponseReceived) -> m ResponseReceived + +-- | Variant of 'Raw' that lets you access the underlying monadic context to process the request. +data RawM deriving (Typeable) + +-- | Just pass the request to the underlying application and serve its response. +-- +-- Example: +-- +-- > type MyApi = "images" :> RawM +-- > +-- > server :: Server MyApi +-- > server = serveDirectory "/var/www/images" +instance HasServer RawM context where + type ServerT RawM m = Request -> (Response -> IO ResponseReceived) -> m ResponseReceived + + route :: + Proxy RawM -> + Context context -> + Delayed env (Request -> (Response -> IO ResponseReceived) -> Handler ResponseReceived) -> + Router env + route _ _ handleDelayed = RawRouter $ \env request respond -> runResourceT $ do + routeResult <- runDelayed handleDelayed env request + let respond' = liftIO . respond + liftIO $ case routeResult of + Route handler -> + runHandler (handler request (respond . Route)) + >>= \case + Left e -> respond' $ FailFatal e + Right a -> pure a + Fail e -> respond' $ Fail e + FailFatal e -> respond' $ FailFatal e + + hoistServerWithContext _ _ f srvM req respond = f (srvM req respond) + +instance HasSwagger RawM where + toSwagger _ = toSwagger (Proxy @Raw) + +instance RoutesToPaths RawM where + getRoutes = [] diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs b/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs new file mode 100644 index 0000000000..8c28f7c6b4 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs @@ -0,0 +1,62 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Public.Proxy where + +import Data.SOP +import qualified Data.Swagger as Swagger +import Servant +import Servant.API.Extended.RawM (RawM) +import Servant.Swagger +import Wire.API.Routes.Named + +type ProxyAPI = + ProxyAPIRoute "giphy-path" ("giphy" :> "v1" :> "gifs" :> RawM) + :<|> ProxyAPIRoute "youtube-path" ("youtube" :> "v3" :> RawM) + :<|> ProxyAPIRoute "gmaps-static" ("googlemaps" :> "api" :> "staticmap" :> RawM) + :<|> ProxyAPIRoute "gmaps-path" ("googlemaps" :> "maps" :> "api" :> "geocode" :> RawM) + +type ProxyAPIRoute name path = Named name (Summary (ProxyAPISummary name) :> "proxy" :> path) + +-- | API docs: if we want to make these longer, they won't clutter the routes above +-- that they document. +-- +-- youtube, google maps are only supported for old android. there is no strong reason to end +-- support at any particular version, except the hope that old android won't need to support +-- V4, and if nobody uses it, we shouldn't serve it. if you are a wire employee, see +-- https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/685867582/Proxy+for+3rd+party+services +-- for discussion. +type family ProxyAPISummary name where + ProxyAPISummary "giphy-path" = + "proxy: `get /proxy/giphy/v1/gifs/:path`; see giphy API docs" + ProxyAPISummary "youtube-path" = + "[DEPRECATED] proxy: `get /proxy/youtube/v3/:path`; see youtube API docs" + ProxyAPISummary "gmaps-static" = + "[DEPRECATED] proxy: `get /proxy/googlemaps/api/staticmap`; see google maps API docs" + ProxyAPISummary "gmaps-path" = + "[DEPRECATED] proxy: `get /proxy/googlemaps/maps/api/geocode/:path`; see google maps API docs" + +-- | FUTUREWORK(fisx): (1) the verb could be added to the swagger docs in the appropriate +-- place here; it's always defined in the `Summary`, but the `RawM` doesn't allow to constrain +-- it. (2) there should be a way to make this more type-safe: `assertMethod` in +-- "Proxy.API.Public" could take a type-level string literal argument containing the method, +-- and that argument could be funnelled there from the routing table somehow: `"spotify" :> +-- "api" :> "token" :> OnlyMethod "POST" :> RawM`, and then the `ServerT` instance for +-- `OnlyMethod` requires a proxy argument in the handler of the same type. Or something. (am +-- i massifly over-engineering things here?) +swaggerDoc :: Swagger.Swagger +swaggerDoc = toSwagger (Proxy @ProxyAPI) diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 843da12233..255c044065 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -105,6 +105,7 @@ library Wire.API.Routes.Public.Galley.TeamConversation Wire.API.Routes.Public.Galley.TeamMember Wire.API.Routes.Public.Gundeck + Wire.API.Routes.Public.Proxy Wire.API.Routes.Public.Spar Wire.API.Routes.Public.Util Wire.API.Routes.QualifiedCapture diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 4975a8b893..bdcf685931 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -120,6 +120,7 @@ import qualified Wire.API.Routes.Public.Cannon as CannonAPI import qualified Wire.API.Routes.Public.Cargohold as CargoholdAPI import qualified Wire.API.Routes.Public.Galley as GalleyAPI import qualified Wire.API.Routes.Public.Gundeck as GundeckAPI +import qualified Wire.API.Routes.Public.Proxy as ProxyAPI import qualified Wire.API.Routes.Public.Spar as SparAPI import qualified Wire.API.Routes.Public.Util as Public import Wire.API.Routes.Version @@ -158,6 +159,7 @@ versionedSwaggerDocsAPI (Just V3) = <> CargoholdAPI.swaggerDoc <> CannonAPI.swaggerDoc <> GundeckAPI.swaggerDoc + <> ProxyAPI.swaggerDoc ) & S.info . S.title .~ "Wire-Server API" & S.info . S.description ?~ $(embedText =<< makeRelativeToProject "docs/swagger.md") diff --git a/services/proxy/src/Proxy/API.hs b/services/proxy/src/Proxy/API.hs index 80c3f943f6..4cb86ef558 100644 --- a/services/proxy/src/Proxy/API.hs +++ b/services/proxy/src/Proxy/API.hs @@ -33,6 +33,10 @@ sitemap e = do Public.sitemap e routesInternal +-- | IF YOU MODIFY THIS, BE AWARE OF: +-- +-- >>> /libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs +-- >>> https://wearezeta.atlassian.net/browse/SQSERVICES-1647 routesInternal :: Routes a Proxy () routesInternal = do head "/i/status" (continue $ const (pure empty)) true diff --git a/services/proxy/src/Proxy/API/Public.hs b/services/proxy/src/Proxy/API/Public.hs index 4abd83367a..6c12b314e4 100644 --- a/services/proxy/src/Proxy/API/Public.hs +++ b/services/proxy/src/Proxy/API/Public.hs @@ -47,6 +47,10 @@ import Proxy.Proxy import System.Logger.Class hiding (Error, info, render) import qualified System.Logger.Class as Logger +-- | IF YOU MODIFY THIS, BE AWARE OF: +-- +-- >>> /libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs +-- >>> https://wearezeta.atlassian.net/browse/SQSERVICES-1647 sitemap :: Env -> Routes a Proxy () sitemap e = do get diff --git a/services/proxy/test/scripts/.gitignore b/services/proxy/test/scripts/.gitignore new file mode 100644 index 0000000000..577f4d21fe --- /dev/null +++ b/services/proxy/test/scripts/.gitignore @@ -0,0 +1 @@ +/proxy-test diff --git a/services/proxy/test/scripts/proxy-test.sh b/services/proxy/test/scripts/proxy-test.sh new file mode 100755 index 0000000000..3f8ee9ed3b --- /dev/null +++ b/services/proxy/test/scripts/proxy-test.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +set -o pipefail +set -o errexit + +cd "$(dirname "${BASH_SOURCE[0]}")" + +echo " +run this script to test proxy on any running wire-server +instance. this replaces more thorough integration tests, since +integration tests for just proxy without the proxied services +installed is hard and inadequate. + +WIRE_BACKEND: $WIRE_BACKEND +WIRE_ADMIN: $WIRE_ADMIN +WIRE_PASSWD: +" + +set -x + +fail() { + printf "\e[31;1m%s\e[0m\n" "$*" >&2 + exit 1 +} + +check_login() { + echo "checking login..." + status_code=$(curl --write-out '%{http_code}' --silent --output /dev/null -I -X GET --header "Authorization: Bearer $BEARER" "$WIRE_BACKEND"/self) + + if [[ "$status_code" == 200 ]]; then + echo "login: OK" + else + echo "status code: $status_code" + echo "this may be because your password contains special characters that would need to be quoted better in this script." + fail "login: FAIL" + fi +} + +check_url() { + export testnum=$1 + export verb=$2 + export uri=$3 + export status_want=$4 + + status_have=$(curl --write-out '%{http_code}' --silent --output "./proxy-test/$testnum.txt" -I -X "$verb" \ + --header "Authorization: Bearer $BEARER" \ + --header "Content-Type: application/json" \ + "$uri") + + curl -X "$verb" \ + --header "Authorization: Bearer $BEARER" \ + --header "Content-Type: application/json" \ + "$uri" > ./proxy-test/"$testnum".json + + if [[ "$status_have" == "$status_want" ]]; then + echo "proxy $uri: OK" + file ./proxy-test/"$testnum".json | grep -q '\(JSON\|PNG\)' || ( echo "received something weird!"; exit 1 ) + else + echo "expected status code: $status_want, but got $status_have" + fail "proxy $uri: FAIL (check ./proxy-test/$testnum.json for details)" + fi +} + +get_access_token() { + BEARER=$(curl -X POST --header 'Content-Type: application/json' --header 'Accept: application/json' \ + -d '{"email":"'"$WIRE_ADMIN"'","password":"'"$WIRE_PASSWD"'"}' \ + "$WIRE_BACKEND"/login'?persist=false' \ + | jq -r .access_token) +} + + +mkdir -p ./proxy-test + +get_access_token +check_login + +check_url "1" "GET" "$WIRE_BACKEND"/api/swagger.json 200 +check_url "2" "GET" "$WIRE_BACKEND"'/v2/proxy/giphy/v1/gifs/search?limit=100&offset=0&q=kitty' 200 +check_url "3" "GET" "$WIRE_BACKEND"'/v2/proxy/youtube/v3/search' 200 +check_url "4" "GET" "$WIRE_BACKEND"'/v2/proxy/googlemaps/api/staticmap?center=Berlin&zoom=14&size=400x400' 200 +check_url "5" "GET" "$WIRE_BACKEND"'/v2/proxy/googlemaps/maps/api/geocode/json?place_id=ChIJeRpOeF67j4AR9ydy_PIzPuM' 200 + +# manually: +# curl -XGET http://localhost:8080/i/status # from proxy pod +# curl -XHEAD http://localhost:8080/i/status # from proxy pod From 6f911515dd50303590d14ed923d67acf4dde7db4 Mon Sep 17 00:00:00 2001 From: fisx Date: Tue, 10 Jan 2023 10:26:29 +0100 Subject: [PATCH 15/33] Fix `make clean`; allow new data constructors in `ToSchema Version`. (#2965) --- Makefile | 1 - changelog.d/5-internal/pr-2965 | 1 + libs/wire-api/src/Wire/API/Routes/Version.hs | 6 +----- 3 files changed, 2 insertions(+), 6 deletions(-) create mode 100644 changelog.d/5-internal/pr-2965 diff --git a/Makefile b/Makefile index dab64d021e..df625369b5 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,6 @@ endif .PHONY: clean clean: cabal clean - $(MAKE) -C services/nginz clean -rm -rf dist .PHONY: clean-hint diff --git a/changelog.d/5-internal/pr-2965 b/changelog.d/5-internal/pr-2965 new file mode 100644 index 0000000000..7540da5e62 --- /dev/null +++ b/changelog.d/5-internal/pr-2965 @@ -0,0 +1 @@ +Fix `make clean`; allow new data constructors in `ToSchema Version` instance diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index 68d46bf8ee..5586be14ba 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -64,11 +64,7 @@ data Version = V0 | V1 | V2 | V3 instance ToSchema Version where schema = enum @Integer "Version" . mconcat $ - [ element 0 V0, - element 1 V1, - element 2 V2, - element 3 V3 - ] + (\v -> element (fromIntegral $ fromEnum v) v) <$> [minBound @Version ..] mkVersion :: Integer -> Maybe Version mkVersion n = case Aeson.fromJSON (Aeson.Number (fromIntegral n)) of From a0815533685fb0e92231a789378e0100e4152464 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Tue, 10 Jan 2023 15:35:38 +0100 Subject: [PATCH 16/33] Optimize memory usage while creating large conversations (#2970) * galley: Avoid duplicate work for conversation-created notificiations For every member in the conversation, the view of the conversation differs as they see their own member metadata in detail which other memebrs' metadata is not so detailed. When a conversation is created, each member of the conversation gets their own view of the convesation in a notification. The `convRemoteMembers` and `convLocalMembers` fields are mostly repeated across all of these views. This commit avoids computing these lists from scratch n times, where n is the number of members in the conversation. * Galley.Intra.Push.Internal: Remove StrictData langauge pragma The pragma causes everything inside `PushTo` to be strict. This causes larger pushes (e.g. a new Conversation notification to n conversation members) to allocate a lot of thunks internally for the `pushJson` field way before they are actually needed causing spike in memory usage. * Galley.Intra.Push.Internal: Refactor logic to chunk pushes Using `foldr` to create chunks caused each list in `[[Gundeck.Push]]` to get allocated and kept alive until it was completed consumed, causing memory spikes. This combined with `mapConcurrently` would cause all of the `Gundeck.Push` objects to get allocated almost at once before they were all consumed. The refactored logic chunks `PushTo a` without using `foldr` and instead implements `chunk` so that it creates the chunks lazily while ensuring that elements of each chunk are also computed lazily. * Galley.Intra.Push.Internal: Use chunked encoding to push notifications This ensures that http-client doesn't try to compute `Content-Length` of a very big JSON which forces the JSON bytestring to get computed and hence allocates a lot of memory. --- .../5-internal/optimize-creating-large-conv | 1 + libs/bilge/src/Bilge/Request.hs | 58 ++++++++++++++++++- services/galley/default.nix | 2 + services/galley/galley.cabal | 2 + services/galley/src/Galley/API/Create.hs | 8 ++- services/galley/src/Galley/API/Error.hs | 1 + services/galley/src/Galley/API/Mapping.hs | 32 ++++++---- .../galley/src/Galley/Intra/Push/Internal.hs | 51 ++++++++++------ .../galley/test/unit/Test/Galley/Mapping.hs | 31 ++++++---- 9 files changed, 143 insertions(+), 43 deletions(-) create mode 100644 changelog.d/5-internal/optimize-creating-large-conv diff --git a/changelog.d/5-internal/optimize-creating-large-conv b/changelog.d/5-internal/optimize-creating-large-conv new file mode 100644 index 0000000000..a4104c8cca --- /dev/null +++ b/changelog.d/5-internal/optimize-creating-large-conv @@ -0,0 +1 @@ +Optimize memory usage while creating large conversations \ No newline at end of file diff --git a/libs/bilge/src/Bilge/Request.hs b/libs/bilge/src/Bilge/Request.hs index 1be626f8f1..aec0cf9c50 100644 --- a/libs/bilge/src/Bilge/Request.hs +++ b/libs/bilge/src/Bilge/Request.hs @@ -28,7 +28,11 @@ module Bilge.Request body, bytes, lbytes, + lbytesChunkedIO, + lbytesRefChunked, + lbytesRefPopper, json, + jsonChunkedIO, content, contentJson, contentProtobuf, @@ -79,7 +83,7 @@ import qualified Data.ByteString.Lazy.Char8 as LC import Data.CaseInsensitive (original) import Data.Id (RequestId (..)) import Imports hiding (intercalate) -import Network.HTTP.Client (Cookie, Request, RequestBody (..)) +import Network.HTTP.Client (Cookie, GivesPopper, Request, RequestBody (..)) import qualified Network.HTTP.Client as Rq import Network.HTTP.Client.Internal (CookieJar (..), brReadSome, throwHttp) import Network.HTTP.Types @@ -191,9 +195,61 @@ bytes = body . RequestBodyBS lbytes :: Lazy.ByteString -> Request -> Request lbytes = body . RequestBodyLBS +-- | Not suitable for @a@ which translates to very large JSON (more than a few megabytes) as the +-- bytestring produced by JSON will get computed and stored as it is in memory +-- in order to compute the @Content-Length@ header. For making a request with +-- big JSON objects, please use @lbytesRefChunked@ json :: ToJSON a => a -> Request -> Request json a = contentJson . lbytes (encode a) +-- | Like @lbytesChunkedIO@ but for sending a JSON body +jsonChunkedIO :: (ToJSON a, MonadIO m) => a -> m (Request -> Request) +jsonChunkedIO a = do + (contentJson .) <$> lbytesChunkedIO (encode a) + +-- | Makes requests with @Transfer-Encoding: chunked@ and no @Content-Length@ +-- header. Tries to ensures that the lazy bytestring is garbage collected as a +-- "chunk" of this bytestring is consumed. Note that it is not possible to +-- guarantee garbage collection as something else holding a reference to this +-- bytestring could stop that from happening. +-- +-- A more straightforward function like this will keep the reference to the +-- complete bytestring, which might be against the idea of using chunked +-- encoding: +-- +-- @ +-- lbytesChunked bs = body (RequestBodyStreamChunked $ lbytesPopper bs) +-- lbytesPopper bs needsPopper = do +-- ref <- newIORef $ LC.toChunks bs +-- lbytesRefPopper ref needsPopper +-- @ +-- +-- This is because the closure for @lbytesPopper@ keeps the reference to @bs@ +-- alive. To avoid this, this function allocates an @IORef@ and passes that to +-- @lbytesRefChunked@. +lbytesChunkedIO :: MonadIO m => Lazy.ByteString -> m (Request -> Request) +lbytesChunkedIO bs = do + chunksRef <- newIORef $ Lazy.toChunks bs + pure $ lbytesRefChunked chunksRef + +-- | Takes @IORef@ to chunks of strict @ByteString@ (perhaps) from a lazy +-- @Lazy.ByteString@, this helps the lazy bytestring get garbage collected as it +-- gets consumed. The request made will have @Transfer-Encoding: chunked@ and no +-- @Content-Length@ header. +-- +-- See @lbytesChunkedIO@ for reference usage. +lbytesRefChunked :: IORef [ByteString] -> Request -> Request +lbytesRefChunked chunksRef = + body (RequestBodyStreamChunked $ lbytesRefPopper chunksRef) + +lbytesRefPopper :: IORef [ByteString] -> GivesPopper () +lbytesRefPopper chunksRef needsPopper = do + let popper = do + atomicModifyIORef chunksRef $ \case + [] -> ([], mempty) + (c : cs) -> (cs, c) + needsPopper popper + accept :: ByteString -> Request -> Request accept = header hAccept diff --git a/services/galley/default.nix b/services/galley/default.nix index cf8abcd206..6b48dee10b 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -348,6 +348,8 @@ mkDerivation { http-types imports lens + polysemy + polysemy-wire-zoo QuickCheck raw-strings-qq safe diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 0c1cac4e69..955c3ac159 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -830,6 +830,8 @@ test-suite galley-tests , http-types , imports , lens + , polysemy + , polysemy-wire-zoo , QuickCheck , raw-strings-qq >=1.0 , safe >=0.3 diff --git a/services/galley/src/Galley/API/Create.hs b/services/galley/src/Galley/API/Create.hs index 23ab271c15..2995ca3f07 100644 --- a/services/galley/src/Galley/API/Create.hs +++ b/services/galley/src/Galley/API/Create.hs @@ -568,14 +568,16 @@ notifyCreatedConversation dtime lusr conn c = do -- of being added to a conversation registerRemoteConversationMemberships now (tDomain lusr) c -- Notify local users - E.push =<< mapM (toPush now) (Data.convLocalMembers c) + let remoteOthers = map remoteMemberToOther $ Data.convRemoteMembers c + localOthers = map (localMemberToOther (tDomain lusr)) $ Data.convLocalMembers c + E.push =<< mapM (toPush now remoteOthers localOthers) (Data.convLocalMembers c) where route | Data.convType c == RegularConv = RouteAny | otherwise = RouteDirect - toPush t m = do + toPush t remoteOthers localOthers m = do let lconv = qualifyAs lusr (Data.convId c) - c' <- conversationView (qualifyAs lusr (lmId m)) c + c' <- conversationViewWithCachedOthers remoteOthers localOthers c (qualifyAs lusr (lmId m)) let e = Event (tUntagged lconv) Nothing (tUntagged lusr) t (EdConversation c') pure $ newPushLocal1 ListComplete (tUnqualified lusr) (ConvEvent e) (list1 (recipient m) []) diff --git a/services/galley/src/Galley/API/Error.hs b/services/galley/src/Galley/API/Error.hs index e43688e0c1..0beb260031 100644 --- a/services/galley/src/Galley/API/Error.hs +++ b/services/galley/src/Galley/API/Error.hs @@ -45,6 +45,7 @@ data InternalError | NoPrekeyForUser | CannotCreateManagedConv | InternalErrorWithDescription LText + deriving (Eq) internalErrorDescription :: InternalError -> LText internalErrorDescription = message . toWai diff --git a/services/galley/src/Galley/API/Mapping.hs b/services/galley/src/Galley/API/Mapping.hs index 530d421eb4..d6bd3d4392 100644 --- a/services/galley/src/Galley/API/Mapping.hs +++ b/services/galley/src/Galley/API/Mapping.hs @@ -17,7 +17,7 @@ module Galley.API.Mapping ( conversationView, - conversationViewMaybe, + conversationViewWithCachedOthers, remoteConversationView, conversationToRemote, localMemberToSelf, @@ -42,14 +42,29 @@ import Wire.API.Federation.API.Galley -- | View for a given user of a stored conversation. -- --- Throws "bad-state" when the user is not part of the conversation. +-- Throws @BadMemberState@ when the user is not part of the conversation. conversationView :: Members '[Error InternalError, P.TinyLog] r => Local UserId -> Data.Conversation -> Sem r Conversation conversationView luid conv = do - let mbConv = conversationViewMaybe luid conv + let remoteOthers = map remoteMemberToOther $ Data.convRemoteMembers conv + localOthers = map (localMemberToOther (tDomain luid)) $ Data.convLocalMembers conv + conversationViewWithCachedOthers remoteOthers localOthers conv luid + +-- | Like 'conversationView' but optimized for situations which could benefit +-- from pre-computing the list of @OtherMember@s in the conversation. For +-- instance, creating @ConvesationView@ for more than 1 member of the same conversation. +conversationViewWithCachedOthers :: + Members '[Error InternalError, P.TinyLog] r => + [OtherMember] -> + [OtherMember] -> + Data.Conversation -> + Local UserId -> + Sem r Conversation +conversationViewWithCachedOthers remoteOthers localOthers conv luid = do + let mbConv = conversationViewMaybe luid remoteOthers localOthers conv maybe memberNotFound pure mbConv where memberNotFound = do @@ -63,14 +78,11 @@ conversationView luid conv = do -- | View for a given user of a stored conversation. -- -- Returns 'Nothing' if the user is not part of the conversation. -conversationViewMaybe :: Local UserId -> Data.Conversation -> Maybe Conversation -conversationViewMaybe luid conv = do - let (selfs, lothers) = partition ((tUnqualified luid ==) . lmId) (Data.convLocalMembers conv) - rothers = Data.convRemoteMembers conv +conversationViewMaybe :: Local UserId -> [OtherMember] -> [OtherMember] -> Data.Conversation -> Maybe Conversation +conversationViewMaybe luid remoteOthers localOthers conv = do + let selfs = filter ((tUnqualified luid ==) . lmId) (Data.convLocalMembers conv) self <- localMemberToSelf luid <$> listToMaybe selfs - let others = - map (localMemberToOther (tDomain luid)) lothers - <> map remoteMemberToOther rothers + let others = filter (\oth -> tUntagged luid /= omQualifiedId oth) localOthers <> remoteOthers pure $ Conversation (tUntagged . qualifyAs luid . convId $ conv) diff --git a/services/galley/src/Galley/Intra/Push/Internal.hs b/services/galley/src/Galley/Intra/Push/Internal.hs index bd0165787f..5232489a3c 100644 --- a/services/galley/src/Galley/Intra/Push/Internal.hs +++ b/services/galley/src/Galley/Intra/Push/Internal.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} -- This file is part of the Wire Server implementation. @@ -25,7 +24,6 @@ import Control.Lens (makeLenses, set, view, (.~)) import Data.Aeson (Object) import Data.Id (ConnId, UserId) import Data.Json.Util -import Data.List.Extra (chunksOf) import Data.List.NonEmpty (NonEmpty, nonEmpty) import Data.List1 import Data.Qualified @@ -39,8 +37,7 @@ import Galley.Types.Conversations.Members import Gundeck.Types.Push.V2 (RecipientClients (..)) import qualified Gundeck.Types.Push.V2 as Gundeck import Imports hiding (forkIO) -import Safe (headDef, tailDef) -import UnliftIO.Async (mapConcurrently) +import UnliftIO.Async (mapConcurrently_) import Wire.API.Event.Conversation (Event (evtFrom)) import qualified Wire.API.Event.FeatureConfig as FeatureConfig import qualified Wire.API.Event.Team as Teams @@ -102,33 +99,51 @@ pushLocal ps = do let limit = currentFanoutLimit opts -- Do not fan out for very large teams let (asyncs, syncs) = partition _pushAsync (removeIfLargeFanout limit $ toList ps) - traverse_ (asyncCall Gundeck . json) (pushes asyncs) - void $ mapConcurrently (call Gundeck . json) (pushes syncs) + traverse_ (asyncCall Gundeck <=< jsonChunkedIO) (pushes asyncs) + mapConcurrently_ (call Gundeck <=< jsonChunkedIO) (pushes syncs) where - pushes = fst . foldr chunk ([], 0) - chunk p (pss, !n) = - let r = recipientList p - nr = length r - in if n + nr > maxRecipients - then - let pss' = map (pure . toPush p) (chunksOf maxRecipients r) - in (pss' ++ pss, 0) - else - let hd = headDef [] pss - tl = tailDef [] pss - in ((toPush p r : hd) : tl, n + nr) + pushes :: [PushTo UserId] -> [[Gundeck.Push]] + pushes = map (map (\p -> toPush p (recipientList p))) . chunk 0 [] + + chunk :: Int -> [PushTo a] -> [PushTo a] -> [[PushTo a]] + chunk _ acc [] = [acc] + chunk n acc (y : ys) + | n >= maxRecipients = acc : chunk 0 [] (y : ys) + | otherwise = + let totalLength = (n + length (_pushRecipients y)) + in if totalLength > maxRecipients + then + let (y1, y2) = splitPush (maxRecipients - n) y + in chunk maxRecipients (y1 : acc) (y2 : ys) + else chunk totalLength (y : acc) ys + + -- n must be strictly > 0 and < length (_pushRecipients p) + splitPush :: Int -> PushTo a -> (PushTo a, PushTo a) + splitPush n p = + let (r1, r2) = splitAt n (toList (_pushRecipients p)) + in (p {_pushRecipients = fromJust $ maybeList1 r1}, p {_pushRecipients = fromJust $ maybeList1 r2}) + + maxRecipients :: Int maxRecipients = 128 + + recipientList :: PushTo UserId -> [Gundeck.Recipient] recipientList p = map (toRecipient p) . toList $ _pushRecipients p + + toPush :: PushTo user -> [Gundeck.Recipient] -> Gundeck.Push toPush p r = let pload = Gundeck.singletonPayload (pushJson p) in Gundeck.newPush (pushOrigin p) (unsafeRange (Set.fromList r)) pload & Gundeck.pushOriginConnection .~ _pushConn p & Gundeck.pushTransient .~ _pushTransient p & maybe id (set Gundeck.pushNativePriority) (_pushNativePriority p) + + toRecipient :: PushTo user -> RecipientBy UserId -> Gundeck.Recipient toRecipient p r = Gundeck.recipient (_recipientUserId r) (_pushRoute p) & Gundeck.recipientClients .~ _recipientClients r + -- Ensure that under no circumstances we exceed the threshold + removeIfLargeFanout :: Integral a => Range n m a -> [PushTo user] -> [PushTo user] removeIfLargeFanout limit = filter ( \p -> diff --git a/services/galley/test/unit/Test/Galley/Mapping.hs b/services/galley/test/unit/Test/Galley/Mapping.hs index 7bffa0c802..b1bd89808d 100644 --- a/services/galley/test/unit/Test/Galley/Mapping.hs +++ b/services/galley/test/unit/Test/Galley/Mapping.hs @@ -25,10 +25,15 @@ import Data.Domain import Data.Id import Data.Qualified import qualified Data.Set as Set +import Galley.API.Error (InternalError) import Galley.API.Mapping import qualified Galley.Data.Conversation as Data import Galley.Types.Conversations.Members import Imports +import Polysemy (Sem) +import qualified Polysemy as P +import qualified Polysemy.Error as P +import qualified Polysemy.TinyLog as P import Test.Tasty import Test.Tasty.QuickCheck import Wire.API.Conversation @@ -38,35 +43,39 @@ import Wire.API.Federation.API.Galley ( RemoteConvMembers (..), RemoteConversation (..), ) +import qualified Wire.Sem.Logger as P + +run :: Sem '[P.TinyLog, P.Error InternalError] a -> Either InternalError a +run = P.run . P.runError . P.discardLogs tests :: TestTree tests = testGroup "ConversationMapping" [ testProperty "conversation view for a valid user is non-empty" $ - \(ConvWithLocalUser c luid) -> isJust (conversationViewMaybe luid c), + \(ConvWithLocalUser c luid) -> isRight (run (conversationView luid c)), testProperty "self user in conversation view is correct" $ \(ConvWithLocalUser c luid) -> - fmap (memId . cmSelf . cnvMembers) (conversationViewMaybe luid c) - == Just (tUntagged luid), + fmap (memId . cmSelf . cnvMembers) (run (conversationView luid c)) + == Right (tUntagged luid), testProperty "conversation view metadata is correct" $ \(ConvWithLocalUser c luid) -> - fmap cnvMetadata (conversationViewMaybe luid c) - == Just (Data.convMetadata c), + fmap cnvMetadata (run (conversationView luid c)) + == Right (Data.convMetadata c), testProperty "other members in conversation view do not contain self" $ - \(ConvWithLocalUser c luid) -> case conversationViewMaybe luid c of - Nothing -> False - Just cnv -> + \(ConvWithLocalUser c luid) -> case run $ conversationView luid c of + Left _ -> False + Right cnv -> tUntagged luid `notElem` map omQualifiedId (cmOthers (cnvMembers cnv)), testProperty "conversation view contains all users" $ \(ConvWithLocalUser c luid) -> - fmap (sort . cnvUids) (conversationViewMaybe luid c) - == Just (sort (convUids (tDomain luid) c)), + fmap (sort . cnvUids) (run (conversationView luid c)) + == Right (sort (convUids (tDomain luid) c)), testProperty "conversation view for an invalid user is empty" $ \(RandomConversation c) luid -> notElem (tUnqualified luid) (map lmId (Data.convLocalMembers c)) ==> - isNothing (conversationViewMaybe luid c), + isLeft (run (conversationView luid c)), testProperty "remote conversation view for a valid user is non-empty" $ \(ConvWithRemoteUser c ruid) dom -> qDomain (tUntagged ruid) /= dom ==> From 787cb3cadc5aef17df8989d2c774b26fdca3c0e8 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 11 Jan 2023 08:53:15 +0100 Subject: [PATCH 17/33] brig: Allow multiple threads to run simulaneously (#2972) `-with-rtsopts=-N1` was set very long time ago when brig started depending on http-client-openssl. It doesn't seem relevant anymore and using multiple cores should improve performance. --- changelog.d/5-internal/brig-multi-core | 1 + services/brig/brig.cabal | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/5-internal/brig-multi-core diff --git a/changelog.d/5-internal/brig-multi-core b/changelog.d/5-internal/brig-multi-core new file mode 100644 index 0000000000..a4b58dd7b8 --- /dev/null +++ b/changelog.d/5-internal/brig-multi-core @@ -0,0 +1 @@ +brig: Allow multiple threads to run simulaneously \ No newline at end of file diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 4377d9f20c..ef708454da 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -361,7 +361,7 @@ executable brig ghc-options: -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -funbox-strict-fields -threaded -with-rtsopts=-N1 -with-rtsopts=-T + -funbox-strict-fields -threaded -with-rtsopts=-N -with-rtsopts=-T -rtsopts build-depends: From 9ca5e9b336fc627eccb021e631d8263d90ec6f8a Mon Sep 17 00:00:00 2001 From: Paolo Capriotti Date: Wed, 11 Jan 2023 14:02:28 +0100 Subject: [PATCH 18/33] Force V2 when serialising conversations in events (#2971) * Force V2 when serialising conversations in events * Update golden tests --- changelog.d/3-bug-fixes/conv-create-v2-schema | 1 + libs/wire-api/src/Wire/API/Conversation.hs | 32 ++++++++++--------- .../src/Wire/API/Event/Conversation.hs | 2 +- .../test/golden/testObject_Event_user_8.json | 3 +- 4 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 changelog.d/3-bug-fixes/conv-create-v2-schema diff --git a/changelog.d/3-bug-fixes/conv-create-v2-schema b/changelog.d/3-bug-fixes/conv-create-v2-schema new file mode 100644 index 0000000000..36a1a64526 --- /dev/null +++ b/changelog.d/3-bug-fixes/conv-create-v2-schema @@ -0,0 +1 @@ +Conversations inside events are now serialised using the format of API V2 diff --git a/libs/wire-api/src/Wire/API/Conversation.hs b/libs/wire-api/src/Wire/API/Conversation.hs index e89ae46107..08982bc472 100644 --- a/libs/wire-api/src/Wire/API/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Conversation.hs @@ -25,6 +25,7 @@ module Wire.API.Conversation ConversationMetadata (..), defConversationMetadata, Conversation (..), + conversationSchema, cnvType, cnvCreator, cnvAccess, @@ -162,6 +163,10 @@ defConversationMetadata creator = cnvmReceiptMode = Nothing } +accessRolesVersionedSchema :: Version -> ObjectSchema SwaggerDoc (Set AccessRole) +accessRolesVersionedSchema v = + if v > V2 then accessRolesSchema else accessRolesSchemaV2 + accessRolesSchema :: ObjectSchema SwaggerDoc (Set AccessRole) accessRolesSchema = field "access_role" (set schema) @@ -269,15 +274,15 @@ cnvReceiptMode :: Conversation -> Maybe ReceiptMode cnvReceiptMode = cnvmReceiptMode . cnvMetadata instance ToSchema Conversation where - schema = conversationSchema accessRolesSchema + schema = conversationSchema V3 instance ToSchema (Versioned 'V2 Conversation) where - schema = Versioned <$> unVersioned .= conversationSchema accessRolesSchemaV2 + schema = Versioned <$> unVersioned .= conversationSchema V2 conversationSchema :: - ObjectSchema SwaggerDoc (Set AccessRole) -> + Version -> ValueSchema NamedSwaggerDoc Conversation -conversationSchema sch = +conversationSchema v = objectWithDocModifier "Conversation" (description ?~ "A conversation object as returned from the server") @@ -285,7 +290,7 @@ conversationSchema sch = <$> cnvQualifiedId .= field "qualified_id" schema <* (qUnqualified . cnvQualifiedId) .= optional (field "id" (deprecatedSchema "qualified_id" schema)) - <*> cnvMetadata .= conversationMetadataObjectSchema sch + <*> cnvMetadata .= conversationMetadataObjectSchema (accessRolesVersionedSchema v) <*> cnvMembers .= field "members" schema <*> cnvProtocol .= protocolSchema @@ -371,7 +376,7 @@ instance ToSchema (Versioned 'V2 (ConversationList Conversation)) where schema = Versioned <$> unVersioned - .= conversationListSchema (conversationSchema accessRolesSchemaV2) + .= conversationListSchema (conversationSchema V2) conversationListSchema :: forall a. @@ -433,24 +438,24 @@ data ConversationsResponse = ConversationsResponse deriving (FromJSON, ToJSON, S.ToSchema) via Schema ConversationsResponse conversationsResponseSchema :: - ObjectSchema SwaggerDoc (Set AccessRole) -> + Version -> ValueSchema NamedSwaggerDoc ConversationsResponse -conversationsResponseSchema sch = +conversationsResponseSchema v = let notFoundDoc = description ?~ "These conversations either don't exist or are deleted." failedDoc = description ?~ "The server failed to fetch these conversations, most likely due to network issues while contacting a remote server" in objectWithDocModifier "ConversationsResponse" (description ?~ "Response object for getting metadata of a list of conversations") $ ConversationsResponse - <$> crFound .= field "found" (array (conversationSchema sch)) + <$> crFound .= field "found" (array (conversationSchema v)) <*> crNotFound .= fieldWithDocModifier "not_found" notFoundDoc (array schema) <*> crFailed .= fieldWithDocModifier "failed" failedDoc (array schema) instance ToSchema ConversationsResponse where - schema = conversationsResponseSchema accessRolesSchema + schema = conversationsResponseSchema V3 instance ToSchema (Versioned 'V2 ConversationsResponse) where - schema = Versioned <$> unVersioned .= conversationsResponseSchema accessRolesSchemaV2 + schema = Versioned <$> unVersioned .= conversationsResponseSchema V2 -------------------------------------------------------------------------------- -- Conversation properties @@ -889,14 +894,11 @@ conversationAccessDataSchema v = object ("ConversationAccessData" <> suffix) $ ConversationAccessData <$> cupAccess .= field "access" (set schema) - <*> cupAccessRoles .= sch + <*> cupAccessRoles .= accessRolesVersionedSchema v where suffix | v == maxBound = "" | otherwise = toUrlPiece v - sch = case v of - V2 -> accessRolesSchemaV2 - _ -> accessRolesSchema instance ToSchema ConversationAccessData where schema = conversationAccessDataSchema V3 diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index 81ff6df00b..326fce09cf 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -385,7 +385,7 @@ taggedEventDataSchema = (unnamed (conversationAccessDataSchema V2)) ConvCodeUpdate -> tag _EdConvCodeUpdate (unnamed schema) ConvConnect -> tag _EdConnect (unnamed schema) - ConvCreate -> tag _EdConversation (unnamed schema) + ConvCreate -> tag _EdConversation (unnamed (conversationSchema V2)) ConvMessageTimerUpdate -> tag _EdConvMessageTimerUpdate (unnamed schema) ConvReceiptModeUpdate -> tag _EdConvReceiptModeUpdate (unnamed schema) OtrMessageAdd -> tag _EdOtrMessage (unnamed schema) diff --git a/libs/wire-api/test/golden/testObject_Event_user_8.json b/libs/wire-api/test/golden/testObject_Event_user_8.json index cfe4ffda5b..8906b27147 100644 --- a/libs/wire-api/test/golden/testObject_Event_user_8.json +++ b/libs/wire-api/test/golden/testObject_Event_user_8.json @@ -10,7 +10,8 @@ "invite", "link" ], - "access_role": [ + "access_role": "non_activated", + "access_role_v2": [ "team_member", "guest", "service" From 2cc7f528befef0b82cbd3db4560d1a4219e3a52e Mon Sep 17 00:00:00 2001 From: Sebastian Willenborg Date: Wed, 11 Jan 2023 16:45:30 +0100 Subject: [PATCH 19/33] docs: add documentation of client id to zauth readme (#2967) * docs: add documentation of client id to zauth readme * docs: make tag specification consistent --- libs/zauth/README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/libs/zauth/README.md b/libs/zauth/README.md index ef15882c19..bd3cc12b48 100644 --- a/libs/zauth/README.md +++ b/libs/zauth/README.md @@ -7,10 +7,10 @@ version ::= "v=" Integer key-index ::= "k=" Integer (> 0) timestamp ::= "d=" Integer (POSIX timestamp, expiration time) type ::= "t=" ("a" | "u" | "b" | "p") ; access, user, bot, provider -tag ::= "l=" ("s" | "" (session or nothing)) +tag ::= "l=" ("s" | "") ; session or nothing type-specific-data ::= | | | -access-data ::= "u=" "." "c=" -user-data ::= "u=" "." "r=" +access-data ::= "u=" "." "c=" ("i=" | "") +user-data ::= "u=" "." "r=" ("i=" | "") bot-data ::= "p=" "." "b=" "." "c=" provider-data ::= "p=" ``` @@ -21,6 +21,10 @@ provider-data ::= "p=" `7B2fdkjqBm0BZEpvF_1itY-W22LM2RWLDIQgu2k7d-BJojlMfyNpVfXYPEQiWpcCztmwZO_yphgKhhtKetiuCw==.v=1.k=1.d=1409335821.t=u.l=.u=c5eda68f-93f3-4413-93fe-d45e81f8a9f9.r=bb3d1d9f` +#### User-Token (with client id) + +`vpJs7PEgwtsuzGlMY0-Vqs22s8o9ZDlp7wJrPmhCgIfg0NoTAxvxq5OtknabLMfNTEW9amn5tyeUM7tbFZABBA==.v=1.k=1.d=1466770905.t=u.l=.u=6562d941-4f40-4db4-b96e-56a06d71c2c3.r=4feacc.i=deadbeef` + ### User-Token (Session) `7CPhoJv6TOYr7epokS6S2pj0nLoV-mJ_o5iRUII3JM5jBItZzluXNNGb-u476EYQM0fpr1qUGK2eRuKCZuELBA==.v=1.k=1.d=1429832092.t=u.l=s.u=161e7fe7-9a71-4ffd-9a79-de9ee2fa178c.r=3f6a49c4` @@ -29,6 +33,10 @@ provider-data ::= "p=" `5Bdn6CnDO2yIng7_MblYFhMNEo27ESsHsZmD40fNpcTdEybk15dw7zUVOcJDeFyf6QbEsZF4ruNKRu1ICmbzCg==.v=1.k=1.d=1419834921.t=a.l=.u=c5eda68f-93f3-4413-93fe-d45e81f8a9f9.c=8875802285613998639` +#### Access-Token (with client id) + +`aEPOxMwUriGEv2qc7Pb672ygy-6VeJ-8VrX3jmwalZr7xygU4izyCWxiT7IXfybnNGIsk1FQPb0RRVPx1s2UCw==.v=1.k=1.d=1466770783.t=a.l=.u=6562d941-4f40-4db4-b96e-56a06d71c2c3.c=11019722839397809329.i=deadbeef` + # Token creation Given: From 65d3190394dbbd361b4765512a6bd5e61a07a93f Mon Sep 17 00:00:00 2001 From: Zebot Date: Thu, 12 Jan 2023 11:31:00 +0000 Subject: [PATCH 20/33] Add changelog for Release 2023-01-12 --- CHANGELOG.md | 186 ++++++++++++++++++ .../0-release-notes/member_clients_migration | 1 - changelog.d/0-release-notes/webapp-upgrade | 1 - changelog.d/1-api-changes/access-role-v3 | 4 - .../added-domain-to-typing-indicator-api | 1 - .../1-api-changes/get-mls-self-conversation | 1 - .../list-mls-self-conversation-automatically | 1 - changelog.d/1-api-changes/mls-enabled-galley | 1 - changelog.d/1-api-changes/mls-flag-galley | 1 - changelog.d/1-api-changes/subconv-field | 1 - .../1-api-changes/system-settings-endpoint | 1 - changelog.d/2-features/coturn-tls | 4 - .../disable-extra-nginz-upstreams-by-default | 10 - changelog.d/2-features/pr-2855 | 1 - changelog.d/2-features/pr-2895 | 1 - changelog.d/2-features/pr-2951 | 1 - changelog.d/2-features/smtp-logging | 1 - changelog.d/2-features/typing-for-federation | 1 - .../2-features/vhost-addressing-for-s3 | 3 - changelog.d/3-bug-fixes/2896 | 1 - .../3-bug-fixes/aws-error-message-parser-bug | 1 - .../3-bug-fixes/client-deletion-ordering | 1 - changelog.d/3-bug-fixes/conv-create-v2-schema | 1 - .../3-bug-fixes/list-self-mls-not-configured | 1 - .../mls-self-conv-not-listed-below-v3 | 1 - changelog.d/3-bug-fixes/pr-2870 | 1 - changelog.d/3-bug-fixes/pr-2960 | 1 - changelog.d/3-bug-fixes/removal-client-check | 1 - changelog.d/3-bug-fixes/sftd-forwards-compat | 1 - .../sftd-restund-coturn-hostname-nodename | 1 - changelog.d/3-bug-fixes/token-client-bug | 1 - .../4-docs/add-proxy-support-to-deeplink | 1 - changelog.d/4-docs/auth-cookie | 1 - changelog.d/4-docs/pr-2889 | 1 - .../5-internal/add-aws-sns-token-invalid-log | 1 - .../5-internal/add-invitation-url-tests | 1 - changelog.d/5-internal/brig-multi-core | 1 - changelog.d/5-internal/buildah-drop-support | 1 - changelog.d/5-internal/debugging-tools | 1 - changelog.d/5-internal/federated-calls-brig | 1 - changelog.d/5-internal/galley-servant-split | 1 - changelog.d/5-internal/intra-listing | 1 - changelog.d/5-internal/makes-federated-call | 1 - changelog.d/5-internal/nginz-nix | 1 - changelog.d/5-internal/nixpkgs-bump | 1 - .../5-internal/optimize-creating-large-conv | 1 - changelog.d/5-internal/polysemy-oom | 1 - changelog.d/5-internal/pr-2815 | 1 - changelog.d/5-internal/pr-2823 | 1 - changelog.d/5-internal/pr-2824 | 1 - changelog.d/5-internal/pr-2840 | 1 - changelog.d/5-internal/pr-2846 | 1 - changelog.d/5-internal/pr-2848 | 1 - changelog.d/5-internal/pr-2850 | 1 - changelog.d/5-internal/pr-2852 | 1 - changelog.d/5-internal/pr-2861 | 1 - changelog.d/5-internal/pr-2878 | 1 - changelog.d/5-internal/pr-2940 | 1 - changelog.d/5-internal/pr-2949 | 1 - changelog.d/5-internal/pr-2957 | 1 - changelog.d/5-internal/pr-2965 | 1 - changelog.d/5-internal/refactor-mls-message | 1 - .../5-internal/remove-hashed-key-queries | 1 - .../5-internal/replay-backend-proposals | 2 - .../rm-unused-remote-conv-list-store-effect | 1 - changelog.d/5-internal/subconv-types | 1 - changelog.d/5-internal/treefmt | 1 - changelog.d/6-federation/mls-flag-brig | 1 - changelog.d/6-federation/split-msg-send-reqs | 1 - changelog.d/6-federation/swagger-extension | 1 - 70 files changed, 186 insertions(+), 87 deletions(-) delete mode 100644 changelog.d/0-release-notes/member_clients_migration delete mode 100644 changelog.d/0-release-notes/webapp-upgrade delete mode 100644 changelog.d/1-api-changes/access-role-v3 delete mode 100644 changelog.d/1-api-changes/added-domain-to-typing-indicator-api delete mode 100644 changelog.d/1-api-changes/get-mls-self-conversation delete mode 100644 changelog.d/1-api-changes/list-mls-self-conversation-automatically delete mode 100644 changelog.d/1-api-changes/mls-enabled-galley delete mode 100644 changelog.d/1-api-changes/mls-flag-galley delete mode 100644 changelog.d/1-api-changes/subconv-field delete mode 100644 changelog.d/1-api-changes/system-settings-endpoint delete mode 100644 changelog.d/2-features/coturn-tls delete mode 100644 changelog.d/2-features/disable-extra-nginz-upstreams-by-default delete mode 100644 changelog.d/2-features/pr-2855 delete mode 100644 changelog.d/2-features/pr-2895 delete mode 100644 changelog.d/2-features/pr-2951 delete mode 100644 changelog.d/2-features/smtp-logging delete mode 100644 changelog.d/2-features/typing-for-federation delete mode 100644 changelog.d/2-features/vhost-addressing-for-s3 delete mode 100644 changelog.d/3-bug-fixes/2896 delete mode 100644 changelog.d/3-bug-fixes/aws-error-message-parser-bug delete mode 100644 changelog.d/3-bug-fixes/client-deletion-ordering delete mode 100644 changelog.d/3-bug-fixes/conv-create-v2-schema delete mode 100644 changelog.d/3-bug-fixes/list-self-mls-not-configured delete mode 100644 changelog.d/3-bug-fixes/mls-self-conv-not-listed-below-v3 delete mode 100644 changelog.d/3-bug-fixes/pr-2870 delete mode 100644 changelog.d/3-bug-fixes/pr-2960 delete mode 100644 changelog.d/3-bug-fixes/removal-client-check delete mode 100644 changelog.d/3-bug-fixes/sftd-forwards-compat delete mode 100644 changelog.d/3-bug-fixes/sftd-restund-coturn-hostname-nodename delete mode 100644 changelog.d/3-bug-fixes/token-client-bug delete mode 100644 changelog.d/4-docs/add-proxy-support-to-deeplink delete mode 100644 changelog.d/4-docs/auth-cookie delete mode 100644 changelog.d/4-docs/pr-2889 delete mode 100644 changelog.d/5-internal/add-aws-sns-token-invalid-log delete mode 100644 changelog.d/5-internal/add-invitation-url-tests delete mode 100644 changelog.d/5-internal/brig-multi-core delete mode 100644 changelog.d/5-internal/buildah-drop-support delete mode 100644 changelog.d/5-internal/debugging-tools delete mode 100644 changelog.d/5-internal/federated-calls-brig delete mode 100644 changelog.d/5-internal/galley-servant-split delete mode 100644 changelog.d/5-internal/intra-listing delete mode 100644 changelog.d/5-internal/makes-federated-call delete mode 100644 changelog.d/5-internal/nginz-nix delete mode 100644 changelog.d/5-internal/nixpkgs-bump delete mode 100644 changelog.d/5-internal/optimize-creating-large-conv delete mode 100644 changelog.d/5-internal/polysemy-oom delete mode 100644 changelog.d/5-internal/pr-2815 delete mode 100644 changelog.d/5-internal/pr-2823 delete mode 100644 changelog.d/5-internal/pr-2824 delete mode 100644 changelog.d/5-internal/pr-2840 delete mode 100644 changelog.d/5-internal/pr-2846 delete mode 100644 changelog.d/5-internal/pr-2848 delete mode 100644 changelog.d/5-internal/pr-2850 delete mode 100644 changelog.d/5-internal/pr-2852 delete mode 100644 changelog.d/5-internal/pr-2861 delete mode 100644 changelog.d/5-internal/pr-2878 delete mode 100644 changelog.d/5-internal/pr-2940 delete mode 100644 changelog.d/5-internal/pr-2949 delete mode 100644 changelog.d/5-internal/pr-2957 delete mode 100644 changelog.d/5-internal/pr-2965 delete mode 100644 changelog.d/5-internal/refactor-mls-message delete mode 100644 changelog.d/5-internal/remove-hashed-key-queries delete mode 100644 changelog.d/5-internal/replay-backend-proposals delete mode 100644 changelog.d/5-internal/rm-unused-remote-conv-list-store-effect delete mode 100644 changelog.d/5-internal/subconv-types delete mode 100644 changelog.d/5-internal/treefmt delete mode 100644 changelog.d/6-federation/mls-flag-brig delete mode 100644 changelog.d/6-federation/split-msg-send-reqs delete mode 100644 changelog.d/6-federation/swagger-extension diff --git a/CHANGELOG.md b/CHANGELOG.md index 266fafbfbc..2369aaa897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,189 @@ +# [2023-01-12] (Chart Release 4.30.0) + +## Release notes + + +* This realease migrates data from `galley.member_client` to `galley.mls_group_member_client`. When upgrading wire-server no manual steps are required. (#2859) + +* Upgrade webapp version to 2022-12-19-production.0-v0.31.9-0-6b2f2bf (#2302) + + +## API changes + + +* - The endpoints `POST /conversations/list` and `GET /conversations` have been removed. Use `POST /conversations/list-ids` followed by `POST /conversations/list` instead. + - The endpoint `PUT /conversations/:id/access` has been removed. Use its qualified counterpart instead. + - The field `access_role_v2` in the `Conversation` type, in the request body of `POST /conversations`, and in the request body of `PUT /conversations/:domain/:id/access` has been removed. Its content is now contained in the `access_role` field instead. It replaces the legacy access role, previously contained in the `access_role` field. + - Clients implementing the V3 API must be prepared to handle a change in the format of the conversation.access_update event. Namely, the field access_role_v2 has become optional. When missing, its value is to be found in the field access_role. (#2841) + +* Added a domain parameter to the typing indicator status update API (#2892) + +* Support MLS self-conversations via a new endpoint `GET /conversations/mls-self`. This removes the `PUT` counterpart introduced in #2730 (#2839) + +* List the MLS self-conversation automatically without needing to call `GET /conversations/mls-self` first (#2856) + +* Fail early in galley when the MLS removal key is not configured (#2899) + +* Introduce a flag in brig to enable MLS explicitly. When this flag is set to false or absent, MLS functionality is completely disabled and all MLS endpoints fail immediately. (#2913) + +* Conversation events may have a "subconv" field for events that originate in a MLS subconversation (#2933) + +* `GET /system/settings/unauthorized` returns a curated set of system settings from brig. The endpoint is reachable without authentication/authorization. It's meant to be used by apps to adjust their behavior (e.g. to show a registration dialog if registrations are enabled on the backend.) Currently, only the `setRestrictUserCreation` flag is exported. Other options may be added in future (in consultation with the security department.) (#2903) + + +## Features + + +* The coturn Helm chart now has a `.tls.ciphers` option to allow setting + the cipher list for TLS connections, when TLS is enabled. By default, + this option is set to a cipher list which is compliant with [BSI + TR-02102-2](https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.pdf). (#2924) + +* **Nginz helm chart**: The list of upstreams is split into `nginx_conf.upstreams` and + `nginx_conf.extra_upstreams`. Extra upstreams are disabled by default. They can + be enabled by adding their name (entry's key) to + `nginx_conf.enabled_extra_upstreams`. `nginx_conf.ignored_upstreams` is only + applied to upstreams from `nginx_conf.upstreams`. In the default configuration + of `nginz` extra upstreams are `ibis`, `galeb`, `calling-test` and `proxy`. If one + of those is deployed, its name has be be added to + `nginx_conf.enabled_extra_upstreams` (otherwise, it won't be reachable). Unless + `nginx_conf.upstreams` hasn't been changed manually (overriding its default), + this should be the only needed migration step. (#2849) + +* A team member's role can now be provisioned via SCIM (#2851, #2855) + +* Team search endpoint now supports pagination (#2898, #2895) + +* Introduce optional disabledAPIVersions configuration setting (#2951) + +* Add more logs to SMTP mail sending. Ensure that logs are written before the application fails due to SMTP misconfiguration. (#2818) + +* Added typing indicator status progation to federated environments (#2892) + +* Allow vhost style addressing for S3 as path style is not supported for newer buckets. + + More info: https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ (#2955) + + +## Bug fixes and other updates + + +* Fix typo for Servicemonitor enable var in default values for helm charts. (#PR_NOT_FOUND) + +* The parser for the AWS/SNS error message to explain that an endpoint is already in use was incorrect. This lead to an "invalid token" error when registering push tokens for multiple user accounts (user ids) instead of updating the SNS endpoint with an additional user id. (#2921) + +* Avoid client deletion edge case condition which can lead to inconsistent data between brig and galley's clients tables. (#2830) + +* Conversations inside events are now serialised using the format of API V2 (#2971) + +* Do not throw 500 when listing conversations and MLS is not configured (#2893) + +* Do not list MLS self-conversation in client API v1 and v2 if it exists (#2872) + +* Prevention of storing unnecessary data in the database if adding a bot to a conversation fails. (#2870) + +* Limit 2FA code retries to 3 attempts (#2960) + +* Fix bug in MLS user removal from conversation: the list of removed clients has to be compared with those in the conversation, not the list of *all* clients of that user (#2817) + +* Due to `sftd` changing how configuration is handled for "multi-SFT" calling (starting with version 3.1.10), new options have been added to the `sftd` Helm chart for compatibility with these newer versions. (#2886) + +* For sftd/coturn/restund, fixed a bug in external ip address lookup, in case Kubernetes Node Name doesn't equal hostname. (#2837) + +* Requesting a new token with the client_id now works correctly when the old token is part of the request (#2860) + + +## Documentation + + +* Add extra section to the deeplink docs to explain the socks proxy support while login. (#PR_NOT_FOUND) + +* Describe the auth cookie throttling mechanism. And overhaul the description of auth cookies in general. (#2941) + +* PR guidelines docs are updated with correct helm configuration syntax (#2889) + + +## Internal changes + + +* Log AWS / SNS invalid token responses. This is helpful for native push notification debugging purposes. (#2908) + +* Add tests for invitation urls in team invitation responses. These depend on the settings of galley. (#2797) + +* brig: Allow multiple threads to run simulaneously (#2972) + +* Remove support for compiling local docker images with buildah. Nix is used to build docker images these days (#2822) + +* Nix-created docker images: add some debugging tools in the containers, and add 'make build-image-' for convenience (#2829) + +* Added typeclasses to track uses of federated calls across the codebase. (#2940) + +* Split galley API routes and handler definitions into several modules (#2820) + +* Default intraListing to true. This means that the list of clients, so far saved in both brig's and galley's databases, will still be written to both, but only read from brig's database. This avoids cases where these two tables go out of sync. Brig becomes the source of truth for clients. In the future, if this holds, code and data for galley's clients table can be removed. (#2847) + +* Introduce the `MakesFederatedCall` Servant combinator (#2950) + +* Build nginz and nginz_disco docker images using nix (#2796) + +* Bump nixpkgs to latest unstable. Stop using forked nixpkgs. (#2828) + +* Optimize memory usage while creating large conversations (#2970) + +* Reduce Polysemy-induced high memory requirements (#2947) + +* Brig calling API is now migrated to servant (#2815) + +* Fixed flaky feature TTL integration test (#2823) + +* Brig teams API is now migrated to servant (#2824) + +* Add 'inconsistencies' tool to check for, and repair certain kinds of data inconsistencies across different cassandra tables. (#2840) + +* Backoffice Swagger 2.x docs is exposed on `/` and the old Swagger has been removed. Backoffice helm chart only runs stern without an extra nginx. (#2846) + +* Give proxy service a servant routing table for swagger (not for replacing wai-route; see comments in source code) (#2848) + +* Stern API endpoint `GET ejpd-info` has now the correct HTTP method (#2850) + +* External commits: add additional checks (#2852) + +* Golden tests for conversation and feature config event schemas (#2861) + +* Add startup probe to brig helm chart. (#2878) + +* Track federated calls in types across the codebase. (#2940) + +* Update nix pins to point at polysemy-1.8.0.0 (#2949) + +* Add MakesFederatedCall combinators to Galley (#2957) + +* Fix `make clean`; allow new data constructors in `ToSchema Version` instance (#2965) + +* Refactor and simplify MLS message handling logic (#2844) + +* Remove cassandra queries to the user_keys_hash table, as they are never read anymore since 'onboarding' / auto-connect was removed in https://github.com/wireapp/wire-server/pull/1005 (#2902) + +* Replay external backend proposals after forwarding external commits. + One column added to Galley's mls_proposal_refs. (#2842) + +* Remove an unused effect for remote conversation listing (#2954) + +* Introduce types for subconversations (#2925) + +* Use treefmt to ensure consistent formatting of .nix files, use for shellcheck too (#2831) + + +## Federation changes + + +* Honour MLS flag in brig's federation API (#2946) + +* Split the Proteus and MLS message sending requests into separate types. The MLS request now supports MLS subconversations. This is a federation API breaking change. (#2925) + +* Injects federated calls into the `x-wire-makes-federated-calls-to` extension of the swagger Operations (#2950) + + # [2022-12-09] (Chart Release ) ## Bug fixes and other updates diff --git a/changelog.d/0-release-notes/member_clients_migration b/changelog.d/0-release-notes/member_clients_migration deleted file mode 100644 index 56b91f7569..0000000000 --- a/changelog.d/0-release-notes/member_clients_migration +++ /dev/null @@ -1 +0,0 @@ -This realease migrates data from `galley.member_client` to `galley.mls_group_member_client`. When upgrading wire-server no manual steps are required. diff --git a/changelog.d/0-release-notes/webapp-upgrade b/changelog.d/0-release-notes/webapp-upgrade deleted file mode 100644 index 55ff460f58..0000000000 --- a/changelog.d/0-release-notes/webapp-upgrade +++ /dev/null @@ -1 +0,0 @@ -Upgrade webapp version to 2022-12-19-production.0-v0.31.9-0-6b2f2bf diff --git a/changelog.d/1-api-changes/access-role-v3 b/changelog.d/1-api-changes/access-role-v3 deleted file mode 100644 index 9f1c57e824..0000000000 --- a/changelog.d/1-api-changes/access-role-v3 +++ /dev/null @@ -1,4 +0,0 @@ -- The endpoints `POST /conversations/list` and `GET /conversations` have been removed. Use `POST /conversations/list-ids` followed by `POST /conversations/list` instead. -- The endpoint `PUT /conversations/:id/access` has been removed. Use its qualified counterpart instead. -- The field `access_role_v2` in the `Conversation` type, in the request body of `POST /conversations`, and in the request body of `PUT /conversations/:domain/:id/access` has been removed. Its content is now contained in the `access_role` field instead. It replaces the legacy access role, previously contained in the `access_role` field. -- Clients implementing the V3 API must be prepared to handle a change in the format of the conversation.access_update event. Namely, the field access_role_v2 has become optional. When missing, its value is to be found in the field access_role. diff --git a/changelog.d/1-api-changes/added-domain-to-typing-indicator-api b/changelog.d/1-api-changes/added-domain-to-typing-indicator-api deleted file mode 100644 index cf431c45fb..0000000000 --- a/changelog.d/1-api-changes/added-domain-to-typing-indicator-api +++ /dev/null @@ -1 +0,0 @@ -Added a domain parameter to the typing indicator status update API diff --git a/changelog.d/1-api-changes/get-mls-self-conversation b/changelog.d/1-api-changes/get-mls-self-conversation deleted file mode 100644 index 27ea693dcd..0000000000 --- a/changelog.d/1-api-changes/get-mls-self-conversation +++ /dev/null @@ -1 +0,0 @@ -Support MLS self-conversations via a new endpoint `GET /conversations/mls-self`. This removes the `PUT` counterpart introduced in #2730 diff --git a/changelog.d/1-api-changes/list-mls-self-conversation-automatically b/changelog.d/1-api-changes/list-mls-self-conversation-automatically deleted file mode 100644 index cc36eb2bb4..0000000000 --- a/changelog.d/1-api-changes/list-mls-self-conversation-automatically +++ /dev/null @@ -1 +0,0 @@ -List the MLS self-conversation automatically without needing to call `GET /conversations/mls-self` first diff --git a/changelog.d/1-api-changes/mls-enabled-galley b/changelog.d/1-api-changes/mls-enabled-galley deleted file mode 100644 index e69819275b..0000000000 --- a/changelog.d/1-api-changes/mls-enabled-galley +++ /dev/null @@ -1 +0,0 @@ -Fail early in galley when the MLS removal key is not configured diff --git a/changelog.d/1-api-changes/mls-flag-galley b/changelog.d/1-api-changes/mls-flag-galley deleted file mode 100644 index 6140f13a00..0000000000 --- a/changelog.d/1-api-changes/mls-flag-galley +++ /dev/null @@ -1 +0,0 @@ -Introduce a flag in brig to enable MLS explicitly. When this flag is set to false or absent, MLS functionality is completely disabled and all MLS endpoints fail immediately. diff --git a/changelog.d/1-api-changes/subconv-field b/changelog.d/1-api-changes/subconv-field deleted file mode 100644 index c716e832a3..0000000000 --- a/changelog.d/1-api-changes/subconv-field +++ /dev/null @@ -1 +0,0 @@ -Conversation events may have a "subconv" field for events that originate in a MLS subconversation diff --git a/changelog.d/1-api-changes/system-settings-endpoint b/changelog.d/1-api-changes/system-settings-endpoint deleted file mode 100644 index 662486f8ee..0000000000 --- a/changelog.d/1-api-changes/system-settings-endpoint +++ /dev/null @@ -1 +0,0 @@ -`GET /system/settings/unauthorized` returns a curated set of system settings from brig. The endpoint is reachable without authentication/authorization. It's meant to be used by apps to adjust their behavior (e.g. to show a registration dialog if registrations are enabled on the backend.) Currently, only the `setRestrictUserCreation` flag is exported. Other options may be added in future (in consultation with the security department.) diff --git a/changelog.d/2-features/coturn-tls b/changelog.d/2-features/coturn-tls deleted file mode 100644 index f193fb9c19..0000000000 --- a/changelog.d/2-features/coturn-tls +++ /dev/null @@ -1,4 +0,0 @@ -The coturn Helm chart now has a `.tls.ciphers` option to allow setting -the cipher list for TLS connections, when TLS is enabled. By default, -this option is set to a cipher list which is compliant with [BSI -TR-02102-2](https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.pdf). diff --git a/changelog.d/2-features/disable-extra-nginz-upstreams-by-default b/changelog.d/2-features/disable-extra-nginz-upstreams-by-default deleted file mode 100644 index 9f12b86686..0000000000 --- a/changelog.d/2-features/disable-extra-nginz-upstreams-by-default +++ /dev/null @@ -1,10 +0,0 @@ -**Nginz helm chart**: The list of upstreams is split into `nginx_conf.upstreams` and -`nginx_conf.extra_upstreams`. Extra upstreams are disabled by default. They can -be enabled by adding their name (entry's key) to -`nginx_conf.enabled_extra_upstreams`. `nginx_conf.ignored_upstreams` is only -applied to upstreams from `nginx_conf.upstreams`. In the default configuration -of `nginz` extra upstreams are `ibis`, `galeb`, `calling-test` and `proxy`. If one -of those is deployed, its name has be be added to -`nginx_conf.enabled_extra_upstreams` (otherwise, it won't be reachable). Unless -`nginx_conf.upstreams` hasn't been changed manually (overriding its default), -this should be the only needed migration step. diff --git a/changelog.d/2-features/pr-2855 b/changelog.d/2-features/pr-2855 deleted file mode 100644 index d85440a577..0000000000 --- a/changelog.d/2-features/pr-2855 +++ /dev/null @@ -1 +0,0 @@ -A team member's role can now be provisioned via SCIM (#2851, #2855) diff --git a/changelog.d/2-features/pr-2895 b/changelog.d/2-features/pr-2895 deleted file mode 100644 index 6ff4a200ea..0000000000 --- a/changelog.d/2-features/pr-2895 +++ /dev/null @@ -1 +0,0 @@ -Team search endpoint now supports pagination (#2898, #2895) diff --git a/changelog.d/2-features/pr-2951 b/changelog.d/2-features/pr-2951 deleted file mode 100644 index 7fe0aeddb9..0000000000 --- a/changelog.d/2-features/pr-2951 +++ /dev/null @@ -1 +0,0 @@ -Introduce optional disabledAPIVersions configuration setting diff --git a/changelog.d/2-features/smtp-logging b/changelog.d/2-features/smtp-logging deleted file mode 100644 index 496d0aebdd..0000000000 --- a/changelog.d/2-features/smtp-logging +++ /dev/null @@ -1 +0,0 @@ -Add more logs to SMTP mail sending. Ensure that logs are written before the application fails due to SMTP misconfiguration. diff --git a/changelog.d/2-features/typing-for-federation b/changelog.d/2-features/typing-for-federation deleted file mode 100644 index 4ca5fedbf8..0000000000 --- a/changelog.d/2-features/typing-for-federation +++ /dev/null @@ -1 +0,0 @@ -Added typing indicator status progation to federated environments diff --git a/changelog.d/2-features/vhost-addressing-for-s3 b/changelog.d/2-features/vhost-addressing-for-s3 deleted file mode 100644 index aca06463e2..0000000000 --- a/changelog.d/2-features/vhost-addressing-for-s3 +++ /dev/null @@ -1,3 +0,0 @@ -Allow vhost style addressing for S3 as path style is not supported for newer buckets. - -More info: https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ \ No newline at end of file diff --git a/changelog.d/3-bug-fixes/2896 b/changelog.d/3-bug-fixes/2896 deleted file mode 100644 index 182d83ca6a..0000000000 --- a/changelog.d/3-bug-fixes/2896 +++ /dev/null @@ -1 +0,0 @@ -Fix typo for Servicemonitor enable var in default values for helm charts. diff --git a/changelog.d/3-bug-fixes/aws-error-message-parser-bug b/changelog.d/3-bug-fixes/aws-error-message-parser-bug deleted file mode 100644 index 9ec72cfb47..0000000000 --- a/changelog.d/3-bug-fixes/aws-error-message-parser-bug +++ /dev/null @@ -1 +0,0 @@ -The parser for the AWS/SNS error message to explain that an endpoint is already in use was incorrect. This lead to an "invalid token" error when registering push tokens for multiple user accounts (user ids) instead of updating the SNS endpoint with an additional user id. diff --git a/changelog.d/3-bug-fixes/client-deletion-ordering b/changelog.d/3-bug-fixes/client-deletion-ordering deleted file mode 100644 index 404d69af2f..0000000000 --- a/changelog.d/3-bug-fixes/client-deletion-ordering +++ /dev/null @@ -1 +0,0 @@ -Avoid client deletion edge case condition which can lead to inconsistent data between brig and galley's clients tables. diff --git a/changelog.d/3-bug-fixes/conv-create-v2-schema b/changelog.d/3-bug-fixes/conv-create-v2-schema deleted file mode 100644 index 36a1a64526..0000000000 --- a/changelog.d/3-bug-fixes/conv-create-v2-schema +++ /dev/null @@ -1 +0,0 @@ -Conversations inside events are now serialised using the format of API V2 diff --git a/changelog.d/3-bug-fixes/list-self-mls-not-configured b/changelog.d/3-bug-fixes/list-self-mls-not-configured deleted file mode 100644 index 74e6066571..0000000000 --- a/changelog.d/3-bug-fixes/list-self-mls-not-configured +++ /dev/null @@ -1 +0,0 @@ -Do not throw 500 when listing conversations and MLS is not configured diff --git a/changelog.d/3-bug-fixes/mls-self-conv-not-listed-below-v3 b/changelog.d/3-bug-fixes/mls-self-conv-not-listed-below-v3 deleted file mode 100644 index d656f28b45..0000000000 --- a/changelog.d/3-bug-fixes/mls-self-conv-not-listed-below-v3 +++ /dev/null @@ -1 +0,0 @@ -Do not list MLS self-conversation in client API v1 and v2 if it exists diff --git a/changelog.d/3-bug-fixes/pr-2870 b/changelog.d/3-bug-fixes/pr-2870 deleted file mode 100644 index 765f957fb3..0000000000 --- a/changelog.d/3-bug-fixes/pr-2870 +++ /dev/null @@ -1 +0,0 @@ -Prevention of storing unnecessary data in the database if adding a bot to a conversation fails. diff --git a/changelog.d/3-bug-fixes/pr-2960 b/changelog.d/3-bug-fixes/pr-2960 deleted file mode 100644 index 47c97848ed..0000000000 --- a/changelog.d/3-bug-fixes/pr-2960 +++ /dev/null @@ -1 +0,0 @@ -Limit 2FA code retries to 3 attempts diff --git a/changelog.d/3-bug-fixes/removal-client-check b/changelog.d/3-bug-fixes/removal-client-check deleted file mode 100644 index 6e62ac234b..0000000000 --- a/changelog.d/3-bug-fixes/removal-client-check +++ /dev/null @@ -1 +0,0 @@ -Fix bug in MLS user removal from conversation: the list of removed clients has to be compared with those in the conversation, not the list of *all* clients of that user diff --git a/changelog.d/3-bug-fixes/sftd-forwards-compat b/changelog.d/3-bug-fixes/sftd-forwards-compat deleted file mode 100644 index 0185b60a80..0000000000 --- a/changelog.d/3-bug-fixes/sftd-forwards-compat +++ /dev/null @@ -1 +0,0 @@ -Due to `sftd` changing how configuration is handled for "multi-SFT" calling (starting with version 3.1.10), new options have been added to the `sftd` Helm chart for compatibility with these newer versions. diff --git a/changelog.d/3-bug-fixes/sftd-restund-coturn-hostname-nodename b/changelog.d/3-bug-fixes/sftd-restund-coturn-hostname-nodename deleted file mode 100644 index fdf9bddc06..0000000000 --- a/changelog.d/3-bug-fixes/sftd-restund-coturn-hostname-nodename +++ /dev/null @@ -1 +0,0 @@ -For sftd/coturn/restund, fixed a bug in external ip address lookup, in case Kubernetes Node Name doesn't equal hostname. diff --git a/changelog.d/3-bug-fixes/token-client-bug b/changelog.d/3-bug-fixes/token-client-bug deleted file mode 100644 index da363e7686..0000000000 --- a/changelog.d/3-bug-fixes/token-client-bug +++ /dev/null @@ -1 +0,0 @@ -Requesting a new token with the client_id now works correctly when the old token is part of the request diff --git a/changelog.d/4-docs/add-proxy-support-to-deeplink b/changelog.d/4-docs/add-proxy-support-to-deeplink deleted file mode 100644 index 757d08b28c..0000000000 --- a/changelog.d/4-docs/add-proxy-support-to-deeplink +++ /dev/null @@ -1 +0,0 @@ -Add extra section to the deeplink docs to explain the socks proxy support while login. \ No newline at end of file diff --git a/changelog.d/4-docs/auth-cookie b/changelog.d/4-docs/auth-cookie deleted file mode 100644 index f14135f4b6..0000000000 --- a/changelog.d/4-docs/auth-cookie +++ /dev/null @@ -1 +0,0 @@ -Describe the auth cookie throttling mechanism. And overhaul the description of auth cookies in general. diff --git a/changelog.d/4-docs/pr-2889 b/changelog.d/4-docs/pr-2889 deleted file mode 100644 index a4f811ceb8..0000000000 --- a/changelog.d/4-docs/pr-2889 +++ /dev/null @@ -1 +0,0 @@ -PR guidelines docs are updated with correct helm configuration syntax diff --git a/changelog.d/5-internal/add-aws-sns-token-invalid-log b/changelog.d/5-internal/add-aws-sns-token-invalid-log deleted file mode 100644 index 7ca8ddf381..0000000000 --- a/changelog.d/5-internal/add-aws-sns-token-invalid-log +++ /dev/null @@ -1 +0,0 @@ -Log AWS / SNS invalid token responses. This is helpful for native push notification debugging purposes. diff --git a/changelog.d/5-internal/add-invitation-url-tests b/changelog.d/5-internal/add-invitation-url-tests deleted file mode 100644 index 1c00b99606..0000000000 --- a/changelog.d/5-internal/add-invitation-url-tests +++ /dev/null @@ -1 +0,0 @@ -Add tests for invitation urls in team invitation responses. These depend on the settings of galley. diff --git a/changelog.d/5-internal/brig-multi-core b/changelog.d/5-internal/brig-multi-core deleted file mode 100644 index a4b58dd7b8..0000000000 --- a/changelog.d/5-internal/brig-multi-core +++ /dev/null @@ -1 +0,0 @@ -brig: Allow multiple threads to run simulaneously \ No newline at end of file diff --git a/changelog.d/5-internal/buildah-drop-support b/changelog.d/5-internal/buildah-drop-support deleted file mode 100644 index 2985ad2882..0000000000 --- a/changelog.d/5-internal/buildah-drop-support +++ /dev/null @@ -1 +0,0 @@ -Remove support for compiling local docker images with buildah. Nix is used to build docker images these days diff --git a/changelog.d/5-internal/debugging-tools b/changelog.d/5-internal/debugging-tools deleted file mode 100644 index ffffed013e..0000000000 --- a/changelog.d/5-internal/debugging-tools +++ /dev/null @@ -1 +0,0 @@ -Nix-created docker images: add some debugging tools in the containers, and add 'make build-image-' for convenience diff --git a/changelog.d/5-internal/federated-calls-brig b/changelog.d/5-internal/federated-calls-brig deleted file mode 100644 index e923838886..0000000000 --- a/changelog.d/5-internal/federated-calls-brig +++ /dev/null @@ -1 +0,0 @@ -Added typeclasses to track uses of federated calls across the codebase. diff --git a/changelog.d/5-internal/galley-servant-split b/changelog.d/5-internal/galley-servant-split deleted file mode 100644 index 450472e718..0000000000 --- a/changelog.d/5-internal/galley-servant-split +++ /dev/null @@ -1 +0,0 @@ -Split galley API routes and handler definitions into several modules diff --git a/changelog.d/5-internal/intra-listing b/changelog.d/5-internal/intra-listing deleted file mode 100644 index b5e726d22a..0000000000 --- a/changelog.d/5-internal/intra-listing +++ /dev/null @@ -1 +0,0 @@ -Default intraListing to true. This means that the list of clients, so far saved in both brig's and galley's databases, will still be written to both, but only read from brig's database. This avoids cases where these two tables go out of sync. Brig becomes the source of truth for clients. In the future, if this holds, code and data for galley's clients table can be removed. diff --git a/changelog.d/5-internal/makes-federated-call b/changelog.d/5-internal/makes-federated-call deleted file mode 100644 index 556a1009b0..0000000000 --- a/changelog.d/5-internal/makes-federated-call +++ /dev/null @@ -1 +0,0 @@ -Introduce the `MakesFederatedCall` Servant combinator diff --git a/changelog.d/5-internal/nginz-nix b/changelog.d/5-internal/nginz-nix deleted file mode 100644 index 4ff00f8ac4..0000000000 --- a/changelog.d/5-internal/nginz-nix +++ /dev/null @@ -1 +0,0 @@ -Build nginz and nginz_disco docker images using nix diff --git a/changelog.d/5-internal/nixpkgs-bump b/changelog.d/5-internal/nixpkgs-bump deleted file mode 100644 index 86b659bfcb..0000000000 --- a/changelog.d/5-internal/nixpkgs-bump +++ /dev/null @@ -1 +0,0 @@ -Bump nixpkgs to latest unstable. Stop using forked nixpkgs. \ No newline at end of file diff --git a/changelog.d/5-internal/optimize-creating-large-conv b/changelog.d/5-internal/optimize-creating-large-conv deleted file mode 100644 index a4104c8cca..0000000000 --- a/changelog.d/5-internal/optimize-creating-large-conv +++ /dev/null @@ -1 +0,0 @@ -Optimize memory usage while creating large conversations \ No newline at end of file diff --git a/changelog.d/5-internal/polysemy-oom b/changelog.d/5-internal/polysemy-oom deleted file mode 100644 index 82b4530ebc..0000000000 --- a/changelog.d/5-internal/polysemy-oom +++ /dev/null @@ -1 +0,0 @@ -Reduce Polysemy-induced high memory requirements diff --git a/changelog.d/5-internal/pr-2815 b/changelog.d/5-internal/pr-2815 deleted file mode 100644 index 4462cf30ca..0000000000 --- a/changelog.d/5-internal/pr-2815 +++ /dev/null @@ -1 +0,0 @@ -Brig calling API is now migrated to servant diff --git a/changelog.d/5-internal/pr-2823 b/changelog.d/5-internal/pr-2823 deleted file mode 100644 index 49626890f6..0000000000 --- a/changelog.d/5-internal/pr-2823 +++ /dev/null @@ -1 +0,0 @@ -Fixed flaky feature TTL integration test diff --git a/changelog.d/5-internal/pr-2824 b/changelog.d/5-internal/pr-2824 deleted file mode 100644 index ae0e234fee..0000000000 --- a/changelog.d/5-internal/pr-2824 +++ /dev/null @@ -1 +0,0 @@ -Brig teams API is now migrated to servant diff --git a/changelog.d/5-internal/pr-2840 b/changelog.d/5-internal/pr-2840 deleted file mode 100644 index 70a1375288..0000000000 --- a/changelog.d/5-internal/pr-2840 +++ /dev/null @@ -1 +0,0 @@ -Add 'inconsistencies' tool to check for, and repair certain kinds of data inconsistencies across different cassandra tables. diff --git a/changelog.d/5-internal/pr-2846 b/changelog.d/5-internal/pr-2846 deleted file mode 100644 index 700a8a5d8d..0000000000 --- a/changelog.d/5-internal/pr-2846 +++ /dev/null @@ -1 +0,0 @@ -Backoffice Swagger 2.x docs is exposed on `/` and the old Swagger has been removed. Backoffice helm chart only runs stern without an extra nginx. diff --git a/changelog.d/5-internal/pr-2848 b/changelog.d/5-internal/pr-2848 deleted file mode 100644 index 44c201f7cd..0000000000 --- a/changelog.d/5-internal/pr-2848 +++ /dev/null @@ -1 +0,0 @@ -Give proxy service a servant routing table for swagger (not for replacing wai-route; see comments in source code) diff --git a/changelog.d/5-internal/pr-2850 b/changelog.d/5-internal/pr-2850 deleted file mode 100644 index 91dd2564ff..0000000000 --- a/changelog.d/5-internal/pr-2850 +++ /dev/null @@ -1 +0,0 @@ -Stern API endpoint `GET ejpd-info` has now the correct HTTP method diff --git a/changelog.d/5-internal/pr-2852 b/changelog.d/5-internal/pr-2852 deleted file mode 100644 index eff5f1bc2b..0000000000 --- a/changelog.d/5-internal/pr-2852 +++ /dev/null @@ -1 +0,0 @@ -External commits: add additional checks diff --git a/changelog.d/5-internal/pr-2861 b/changelog.d/5-internal/pr-2861 deleted file mode 100644 index 226e80aa78..0000000000 --- a/changelog.d/5-internal/pr-2861 +++ /dev/null @@ -1 +0,0 @@ -Golden tests for conversation and feature config event schemas diff --git a/changelog.d/5-internal/pr-2878 b/changelog.d/5-internal/pr-2878 deleted file mode 100644 index ad5ba5d55e..0000000000 --- a/changelog.d/5-internal/pr-2878 +++ /dev/null @@ -1 +0,0 @@ -Add startup probe to brig helm chart. diff --git a/changelog.d/5-internal/pr-2940 b/changelog.d/5-internal/pr-2940 deleted file mode 100644 index 90ec15d754..0000000000 --- a/changelog.d/5-internal/pr-2940 +++ /dev/null @@ -1 +0,0 @@ -Track federated calls in types across the codebase. diff --git a/changelog.d/5-internal/pr-2949 b/changelog.d/5-internal/pr-2949 deleted file mode 100644 index 96f68ed183..0000000000 --- a/changelog.d/5-internal/pr-2949 +++ /dev/null @@ -1 +0,0 @@ -Update nix pins to point at polysemy-1.8.0.0 diff --git a/changelog.d/5-internal/pr-2957 b/changelog.d/5-internal/pr-2957 deleted file mode 100644 index 220d55e5f8..0000000000 --- a/changelog.d/5-internal/pr-2957 +++ /dev/null @@ -1 +0,0 @@ -Add MakesFederatedCall combinators to Galley diff --git a/changelog.d/5-internal/pr-2965 b/changelog.d/5-internal/pr-2965 deleted file mode 100644 index 7540da5e62..0000000000 --- a/changelog.d/5-internal/pr-2965 +++ /dev/null @@ -1 +0,0 @@ -Fix `make clean`; allow new data constructors in `ToSchema Version` instance diff --git a/changelog.d/5-internal/refactor-mls-message b/changelog.d/5-internal/refactor-mls-message deleted file mode 100644 index 6cbf9538d5..0000000000 --- a/changelog.d/5-internal/refactor-mls-message +++ /dev/null @@ -1 +0,0 @@ -Refactor and simplify MLS message handling logic diff --git a/changelog.d/5-internal/remove-hashed-key-queries b/changelog.d/5-internal/remove-hashed-key-queries deleted file mode 100644 index fb1e94dd5d..0000000000 --- a/changelog.d/5-internal/remove-hashed-key-queries +++ /dev/null @@ -1 +0,0 @@ -Remove cassandra queries to the user_keys_hash table, as they are never read anymore since 'onboarding' / auto-connect was removed in https://github.com/wireapp/wire-server/pull/1005 diff --git a/changelog.d/5-internal/replay-backend-proposals b/changelog.d/5-internal/replay-backend-proposals deleted file mode 100644 index 4430e0c96a..0000000000 --- a/changelog.d/5-internal/replay-backend-proposals +++ /dev/null @@ -1,2 +0,0 @@ -Replay external backend proposals after forwarding external commits. -One column added to Galley's mls_proposal_refs. diff --git a/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect b/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect deleted file mode 100644 index eb2d1ade2c..0000000000 --- a/changelog.d/5-internal/rm-unused-remote-conv-list-store-effect +++ /dev/null @@ -1 +0,0 @@ -Remove an unused effect for remote conversation listing diff --git a/changelog.d/5-internal/subconv-types b/changelog.d/5-internal/subconv-types deleted file mode 100644 index 77b3a4836b..0000000000 --- a/changelog.d/5-internal/subconv-types +++ /dev/null @@ -1 +0,0 @@ -Introduce types for subconversations diff --git a/changelog.d/5-internal/treefmt b/changelog.d/5-internal/treefmt deleted file mode 100644 index e3e735311a..0000000000 --- a/changelog.d/5-internal/treefmt +++ /dev/null @@ -1 +0,0 @@ -Use treefmt to ensure consistent formatting of .nix files, use for shellcheck too (#2831) diff --git a/changelog.d/6-federation/mls-flag-brig b/changelog.d/6-federation/mls-flag-brig deleted file mode 100644 index 5ce7c8417e..0000000000 --- a/changelog.d/6-federation/mls-flag-brig +++ /dev/null @@ -1 +0,0 @@ -Honour MLS flag in brig's federation API diff --git a/changelog.d/6-federation/split-msg-send-reqs b/changelog.d/6-federation/split-msg-send-reqs deleted file mode 100644 index 6d888a9c80..0000000000 --- a/changelog.d/6-federation/split-msg-send-reqs +++ /dev/null @@ -1 +0,0 @@ -Split the Proteus and MLS message sending requests into separate types. The MLS request now supports MLS subconversations. This is a federation API breaking change. diff --git a/changelog.d/6-federation/swagger-extension b/changelog.d/6-federation/swagger-extension deleted file mode 100644 index 8b7c65135a..0000000000 --- a/changelog.d/6-federation/swagger-extension +++ /dev/null @@ -1 +0,0 @@ -Injects federated calls into the `x-wire-makes-federated-calls-to` extension of the swagger Operations From 3176eef036e7148e7e5e37ee6e9a21574b0a6cf5 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 12 Jan 2023 12:40:50 +0100 Subject: [PATCH 21/33] updated changelog --- CHANGELOG.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2369aaa897..c35bd5ca66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ * Add more logs to SMTP mail sending. Ensure that logs are written before the application fails due to SMTP misconfiguration. (#2818) -* Added typing indicator status progation to federated environments (#2892) +* Added typing indicator status propagation to federated environments (#2892) * Allow vhost style addressing for S3 as path style is not supported for newer buckets. @@ -68,7 +68,7 @@ ## Bug fixes and other updates -* Fix typo for Servicemonitor enable var in default values for helm charts. (#PR_NOT_FOUND) +* Fix typo for Servicemonitor enable var in default values for helm charts. (#2896) * The parser for the AWS/SNS error message to explain that an endpoint is already in use was incorrect. This lead to an "invalid token" error when registering push tokens for multiple user accounts (user ids) instead of updating the SNS endpoint with an additional user id. (#2921) @@ -80,8 +80,6 @@ * Do not list MLS self-conversation in client API v1 and v2 if it exists (#2872) -* Prevention of storing unnecessary data in the database if adding a bot to a conversation fails. (#2870) - * Limit 2FA code retries to 3 attempts (#2960) * Fix bug in MLS user removal from conversation: the list of removed clients has to be compared with those in the conversation, not the list of *all* clients of that user (#2817) @@ -96,7 +94,7 @@ ## Documentation -* Add extra section to the deeplink docs to explain the socks proxy support while login. (#PR_NOT_FOUND) +* Add extra section to the deeplink docs to explain the socks proxy support while login. (#2885) * Describe the auth cookie throttling mechanism. And overhaul the description of auth cookies in general. (#2941) @@ -110,7 +108,7 @@ * Add tests for invitation urls in team invitation responses. These depend on the settings of galley. (#2797) -* brig: Allow multiple threads to run simulaneously (#2972) +* brig: Allow multiple threads to run simultaneously (#2972) * Remove support for compiling local docker images with buildah. Nix is used to build docker images these days (#2822) @@ -124,8 +122,6 @@ * Introduce the `MakesFederatedCall` Servant combinator (#2950) -* Build nginz and nginz_disco docker images using nix (#2796) - * Bump nixpkgs to latest unstable. Stop using forked nixpkgs. (#2828) * Optimize memory usage while creating large conversations (#2970) @@ -184,7 +180,7 @@ * Injects federated calls into the `x-wire-makes-federated-calls-to` extension of the swagger Operations (#2950) -# [2022-12-09] (Chart Release ) +# [2022-12-09] (Chart Release 4.29.0) ## Bug fixes and other updates From 36507cf671063c0bdea120378c4c629dcec0d730 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Thu, 12 Jan 2023 17:22:31 +0100 Subject: [PATCH 22/33] Convert docs from reStructuredText to MyST markdown (#2980) --- docs/convert/compare_screenshots.py | 16 + docs/convert/config.yaml | 1 + docs/convert/conversions.yaml | 1 + docs/convert/convert.sh | 12 + docs/convert/revert.sh | 4 + docs/convert/screenshots.py | 47 + docs/convert/shell.nix | 17 + docs/src/conf.py | 3 +- docs/src/developer/developer/index.md | 10 + docs/src/developer/developer/index.rst | 10 - docs/src/developer/{index.rst => index.md} | 20 +- docs/src/developer/reference/index.md | 10 + docs/src/developer/reference/index.rst | 10 - .../src/developer/reference/spar-braindump.md | 2 +- docs/src/how-to/administrate/cassandra.md | 63 + docs/src/how-to/administrate/cassandra.rst | 65 - docs/src/how-to/administrate/elasticsearch.md | 127 ++ .../src/how-to/administrate/elasticsearch.rst | 134 -- docs/src/how-to/administrate/etcd.md | 261 ++++ docs/src/how-to/administrate/etcd.rst | 264 ---- docs/src/how-to/administrate/general-linux.md | 67 + .../src/how-to/administrate/general-linux.rst | 69 - docs/src/how-to/administrate/index.md | 12 + docs/src/how-to/administrate/index.rst | 14 - .../kubernetes/certificate-renewal/index.md | 10 + .../kubernetes/certificate-renewal/index.rst | 10 - .../scenario-1_k8s-v1.14-kubespray.md | 241 ++++ .../scenario-1_k8s-v1.14-kubespray.rst | 244 ---- .../how-to/administrate/kubernetes/index.md | 20 + .../how-to/administrate/kubernetes/index.rst | 21 - .../kubernetes/restart-machines/index.md | 42 + .../kubernetes/restart-machines/index.rst | 45 - .../kubernetes/upgrade-cluster/index.md | 75 ++ .../kubernetes/upgrade-cluster/index.rst | 82 -- .../administrate/{minio.rst => minio.md} | 256 ++-- docs/src/how-to/administrate/operations.md | 139 +++ docs/src/how-to/administrate/operations.rst | 144 --- docs/src/how-to/administrate/restund.md | 293 +++++ docs/src/how-to/administrate/restund.rst | 301 ----- docs/src/how-to/administrate/users.md | 590 +++++++++ docs/src/how-to/administrate/users.rst | 609 --------- .../custom-backend-for-desktop-client.md | 79 ++ .../custom-backend-for-desktop-client.rst | 90 -- ...ertificates.rst => custom-certificates.md} | 5 +- docs/src/how-to/associate/deeplink.md | 174 +++ docs/src/how-to/associate/deeplink.rst | 174 --- docs/src/how-to/associate/index.md | 10 + docs/src/how-to/associate/index.rst | 10 - docs/src/how-to/index.md | 20 + docs/src/how-to/index.rst | 21 - docs/src/how-to/install/ansible-VMs.md | 275 ++++ docs/src/how-to/install/ansible-VMs.rst | 277 ----- .../how-to/install/ansible-authentication.md | 63 + .../how-to/install/ansible-authentication.rst | 66 - docs/src/how-to/install/ansible-tinc.md | 54 + docs/src/how-to/install/ansible-tinc.rst | 54 - docs/src/how-to/install/aws-prod.md | 36 + docs/src/how-to/install/aws-prod.rst | 39 - .../how-to/install/configuration-options.md | 1048 ++++++++++++++++ .../how-to/install/configuration-options.rst | 1106 ----------------- docs/src/how-to/install/dependencies.md | 69 + docs/src/how-to/install/dependencies.rst | 74 -- docs/src/how-to/install/helm-prod.md | 208 ++++ docs/src/how-to/install/helm-prod.rst | 225 ---- docs/src/how-to/install/helm.md | 145 +++ docs/src/how-to/install/helm.rst | 154 --- .../install/includes/dns-federation.rst | 43 - .../helm_dns-ingress-troubleshooting.inc.rst | 2 - docs/src/how-to/install/index.md | 30 + docs/src/how-to/install/index.rst | 30 - docs/src/how-to/install/kubernetes.md | 85 ++ docs/src/how-to/install/kubernetes.rst | 83 -- .../install/{logging.rst => logging.md} | 160 ++- .../install/{monitoring.rst => monitoring.md} | 14 +- .../install/{planning.rst => planning.md} | 57 +- docs/src/how-to/install/prod-intro.md | 58 + docs/src/how-to/install/prod-intro.rst | 60 - docs/src/how-to/install/restund.md | 80 ++ docs/src/how-to/install/restund.rst | 88 -- docs/src/how-to/install/{sft.rst => sft.md} | 162 ++- docs/src/how-to/install/tls.md | 52 + docs/src/how-to/install/tls.rst | 60 - docs/src/how-to/install/troubleshooting.md | 265 ++++ docs/src/how-to/install/troubleshooting.rst | 255 ---- .../how-to/install/version-requirements.md | 28 + .../how-to/install/version-requirements.rst | 35 - .../post-install/{index.rst => index.md} | 16 +- .../how-to/post-install/logrotation-check.md | 81 ++ .../how-to/post-install/logrotation-check.rst | 79 -- docs/src/how-to/post-install/ntp-check.md | 44 + docs/src/how-to/post-install/ntp-check.rst | 48 - docs/src/how-to/single-sign-on/adfs/main.md | 41 + docs/src/how-to/single-sign-on/adfs/main.rst | 19 - docs/src/how-to/single-sign-on/azure/main.md | 92 ++ docs/src/how-to/single-sign-on/azure/main.rst | 82 -- .../centrify/{main.rst => main.md} | 66 +- .../how-to/single-sign-on/generic-setup.md | 37 + .../how-to/single-sign-on/generic-setup.rst | 42 - docs/src/how-to/single-sign-on/index.md | 15 + .../single-sign-on/okta/{main.rst => main.md} | 62 +- ...ouble-shooting.rst => trouble-shooting.md} | 221 ++-- .../Wire_SAML_Flow (lucidchart).svg | 0 .../understand}/Wire_SAML_Flow.png | Bin .../how-to/single-sign-on/understand/main.md | 561 +++++++++ .../understand}/token-step-01.png | Bin .../understand}/token-step-02.png | Bin .../understand}/token-step-03.png | Bin .../understand}/token-step-04.png | Bin .../understand}/token-step-05.png | Bin .../understand}/token-step-06.png | Bin docs/src/index.md | 46 + docs/src/index.rst | 43 - .../{release-notes.rst => release-notes.md} | 13 +- .../2021-12-15_log4shell.md | 90 ++ .../2021-12-15_log4shell.rst | 103 -- docs/src/security-responses/index.md | 14 + docs/src/security-responses/index.rst | 16 - .../api-client-perspective/authentication.md | 435 +++++++ .../api-client-perspective/authentication.rst | 476 ------- .../api-client-perspective/index.md | 15 + .../api-client-perspective/index.rst | 14 - .../{swagger.rst => swagger.md} | 17 +- docs/src/understand/federation/index.md | 24 + docs/src/understand/federation/index.rst | 25 - docs/src/understand/helm.md | 61 + docs/src/understand/helm.rst | 64 - docs/src/understand/index.md | 17 + docs/src/understand/index.rst | 17 - docs/src/understand/{minio.rst => minio.md} | 13 +- docs/src/understand/notes/port-ranges.md | 36 + docs/src/understand/notes/port-ranges.rst | 36 - docs/src/understand/overview.md | 143 +++ docs/src/understand/overview.rst | 148 --- .../understand/{restund.rst => restund.md} | 110 +- docs/src/understand/{sft.rst => sft.md} | 97 +- docs/src/understand/single-sign-on/design.rst | 3 - docs/src/understand/single-sign-on/main.rst | 560 --------- 137 files changed, 7202 insertions(+), 7424 deletions(-) create mode 100644 docs/convert/compare_screenshots.py create mode 100644 docs/convert/config.yaml create mode 100644 docs/convert/conversions.yaml create mode 100644 docs/convert/convert.sh create mode 100644 docs/convert/revert.sh create mode 100644 docs/convert/screenshots.py create mode 100644 docs/convert/shell.nix create mode 100644 docs/src/developer/developer/index.md delete mode 100644 docs/src/developer/developer/index.rst rename docs/src/developer/{index.rst => index.md} (52%) create mode 100644 docs/src/developer/reference/index.md delete mode 100644 docs/src/developer/reference/index.rst create mode 100644 docs/src/how-to/administrate/cassandra.md delete mode 100644 docs/src/how-to/administrate/cassandra.rst create mode 100644 docs/src/how-to/administrate/elasticsearch.md delete mode 100644 docs/src/how-to/administrate/elasticsearch.rst create mode 100644 docs/src/how-to/administrate/etcd.md delete mode 100644 docs/src/how-to/administrate/etcd.rst create mode 100644 docs/src/how-to/administrate/general-linux.md delete mode 100644 docs/src/how-to/administrate/general-linux.rst create mode 100644 docs/src/how-to/administrate/index.md delete mode 100644 docs/src/how-to/administrate/index.rst create mode 100644 docs/src/how-to/administrate/kubernetes/certificate-renewal/index.md delete mode 100644 docs/src/how-to/administrate/kubernetes/certificate-renewal/index.rst create mode 100644 docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.md delete mode 100644 docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.rst create mode 100644 docs/src/how-to/administrate/kubernetes/index.md delete mode 100644 docs/src/how-to/administrate/kubernetes/index.rst create mode 100644 docs/src/how-to/administrate/kubernetes/restart-machines/index.md delete mode 100644 docs/src/how-to/administrate/kubernetes/restart-machines/index.rst create mode 100644 docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.md delete mode 100644 docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.rst rename docs/src/how-to/administrate/{minio.rst => minio.md} (58%) create mode 100644 docs/src/how-to/administrate/operations.md delete mode 100644 docs/src/how-to/administrate/operations.rst create mode 100644 docs/src/how-to/administrate/restund.md delete mode 100644 docs/src/how-to/administrate/restund.rst create mode 100644 docs/src/how-to/administrate/users.md delete mode 100644 docs/src/how-to/administrate/users.rst create mode 100644 docs/src/how-to/associate/custom-backend-for-desktop-client.md delete mode 100644 docs/src/how-to/associate/custom-backend-for-desktop-client.rst rename docs/src/how-to/associate/{custom-certificates.rst => custom-certificates.md} (80%) create mode 100644 docs/src/how-to/associate/deeplink.md delete mode 100644 docs/src/how-to/associate/deeplink.rst create mode 100644 docs/src/how-to/associate/index.md delete mode 100644 docs/src/how-to/associate/index.rst create mode 100644 docs/src/how-to/index.md delete mode 100644 docs/src/how-to/index.rst create mode 100644 docs/src/how-to/install/ansible-VMs.md delete mode 100644 docs/src/how-to/install/ansible-VMs.rst create mode 100644 docs/src/how-to/install/ansible-authentication.md delete mode 100644 docs/src/how-to/install/ansible-authentication.rst create mode 100644 docs/src/how-to/install/ansible-tinc.md delete mode 100644 docs/src/how-to/install/ansible-tinc.rst create mode 100644 docs/src/how-to/install/aws-prod.md delete mode 100644 docs/src/how-to/install/aws-prod.rst create mode 100644 docs/src/how-to/install/configuration-options.md delete mode 100644 docs/src/how-to/install/configuration-options.rst create mode 100644 docs/src/how-to/install/dependencies.md delete mode 100644 docs/src/how-to/install/dependencies.rst create mode 100644 docs/src/how-to/install/helm-prod.md delete mode 100644 docs/src/how-to/install/helm-prod.rst create mode 100644 docs/src/how-to/install/helm.md delete mode 100644 docs/src/how-to/install/helm.rst delete mode 100644 docs/src/how-to/install/includes/dns-federation.rst create mode 100644 docs/src/how-to/install/index.md delete mode 100644 docs/src/how-to/install/index.rst create mode 100644 docs/src/how-to/install/kubernetes.md delete mode 100644 docs/src/how-to/install/kubernetes.rst rename docs/src/how-to/install/{logging.rst => logging.md} (60%) rename docs/src/how-to/install/{monitoring.rst => monitoring.md} (58%) rename docs/src/how-to/install/{planning.rst => planning.md} (55%) create mode 100644 docs/src/how-to/install/prod-intro.md delete mode 100644 docs/src/how-to/install/prod-intro.rst create mode 100644 docs/src/how-to/install/restund.md delete mode 100644 docs/src/how-to/install/restund.rst rename docs/src/how-to/install/{sft.rst => sft.md} (67%) create mode 100644 docs/src/how-to/install/tls.md delete mode 100644 docs/src/how-to/install/tls.rst create mode 100644 docs/src/how-to/install/troubleshooting.md delete mode 100644 docs/src/how-to/install/troubleshooting.rst create mode 100644 docs/src/how-to/install/version-requirements.md delete mode 100644 docs/src/how-to/install/version-requirements.rst rename docs/src/how-to/post-install/{index.rst => index.md} (53%) create mode 100644 docs/src/how-to/post-install/logrotation-check.md delete mode 100644 docs/src/how-to/post-install/logrotation-check.rst create mode 100644 docs/src/how-to/post-install/ntp-check.md delete mode 100644 docs/src/how-to/post-install/ntp-check.rst create mode 100644 docs/src/how-to/single-sign-on/adfs/main.md delete mode 100644 docs/src/how-to/single-sign-on/adfs/main.rst create mode 100644 docs/src/how-to/single-sign-on/azure/main.md delete mode 100644 docs/src/how-to/single-sign-on/azure/main.rst rename docs/src/how-to/single-sign-on/centrify/{main.rst => main.md} (55%) create mode 100644 docs/src/how-to/single-sign-on/generic-setup.md delete mode 100644 docs/src/how-to/single-sign-on/generic-setup.rst create mode 100644 docs/src/how-to/single-sign-on/index.md rename docs/src/how-to/single-sign-on/okta/{main.rst => main.md} (73%) rename docs/src/how-to/single-sign-on/{trouble-shooting.rst => trouble-shooting.md} (60%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/Wire_SAML_Flow (lucidchart).svg (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/Wire_SAML_Flow.png (100%) create mode 100644 docs/src/how-to/single-sign-on/understand/main.md rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-01.png (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-02.png (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-03.png (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-04.png (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-05.png (100%) rename docs/src/{understand/single-sign-on => how-to/single-sign-on/understand}/token-step-06.png (100%) create mode 100644 docs/src/index.md delete mode 100644 docs/src/index.rst rename docs/src/{release-notes.rst => release-notes.md} (51%) create mode 100644 docs/src/security-responses/2021-12-15_log4shell.md delete mode 100644 docs/src/security-responses/2021-12-15_log4shell.rst create mode 100644 docs/src/security-responses/index.md delete mode 100644 docs/src/security-responses/index.rst create mode 100644 docs/src/understand/api-client-perspective/authentication.md delete mode 100644 docs/src/understand/api-client-perspective/authentication.rst create mode 100644 docs/src/understand/api-client-perspective/index.md delete mode 100644 docs/src/understand/api-client-perspective/index.rst rename docs/src/understand/api-client-perspective/{swagger.rst => swagger.md} (67%) create mode 100644 docs/src/understand/federation/index.md delete mode 100644 docs/src/understand/federation/index.rst create mode 100644 docs/src/understand/helm.md delete mode 100644 docs/src/understand/helm.rst create mode 100644 docs/src/understand/index.md delete mode 100644 docs/src/understand/index.rst rename docs/src/understand/{minio.rst => minio.md} (86%) create mode 100644 docs/src/understand/notes/port-ranges.md delete mode 100644 docs/src/understand/notes/port-ranges.rst create mode 100644 docs/src/understand/overview.md delete mode 100644 docs/src/understand/overview.rst rename docs/src/understand/{restund.rst => restund.md} (61%) rename docs/src/understand/{sft.rst => sft.md} (77%) delete mode 100644 docs/src/understand/single-sign-on/design.rst delete mode 100644 docs/src/understand/single-sign-on/main.rst diff --git a/docs/convert/compare_screenshots.py b/docs/convert/compare_screenshots.py new file mode 100644 index 0000000000..c5b4d9eca1 --- /dev/null +++ b/docs/convert/compare_screenshots.py @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +import subprocess +import os + +output = subprocess.check_output(['find', 'screenshots', '-name', '*_dev.png']).decode('utf8') + +for dev in output.splitlines(): + ref = dev.replace('_dev.png', '_ref.png') + if os.path.exists(dev) and os.path.exists(ref): + print(dev) + cmd = ['compare', '-compose', 'src', dev, ref, dev.replace('_dev.png', '_diff.png')] + print(cmd) + subprocess.run(cmd) + else: + print(f'Cannot compare {dev}') diff --git a/docs/convert/config.yaml b/docs/convert/config.yaml new file mode 100644 index 0000000000..78f2c64c8f --- /dev/null +++ b/docs/convert/config.yaml @@ -0,0 +1 @@ +colon_fences: false diff --git a/docs/convert/conversions.yaml b/docs/convert/conversions.yaml new file mode 100644 index 0000000000..cbeb844cb6 --- /dev/null +++ b/docs/convert/conversions.yaml @@ -0,0 +1 @@ +sphinx.domains.std.Glossary: eval_rst diff --git a/docs/convert/convert.sh b/docs/convert/convert.sh new file mode 100644 index 0000000000..11a4a33fe7 --- /dev/null +++ b/docs/convert/convert.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +set -e +# shellcheck disable=SC2044,SC3010 +for f in $(find . -type f -name '*.rst'); do + if [[ "$f" == */includes/* ]]; then + echo skipping "$f" + continue + fi + rst2myst convert -c convert/conversions.yaml --no-colon-fences "$f" + rm -f "$f" +done diff --git a/docs/convert/revert.sh b/docs/convert/revert.sh new file mode 100644 index 0000000000..df5cf912b3 --- /dev/null +++ b/docs/convert/revert.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh + +git checkout src +git clean src -f diff --git a/docs/convert/screenshots.py b/docs/convert/screenshots.py new file mode 100644 index 0000000000..ff172710d5 --- /dev/null +++ b/docs/convert/screenshots.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +from selenium import webdriver +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.common.by import By +import subprocess +import os.path + +def sanitize_name(name): + r = '' + for c in name: + if c.isalpha(): + r += c + else: + r += '_' + return r + +driver = webdriver.Firefox() + +output = subprocess.check_output(['find', 'build', '-name', '*.html']).decode('utf8') +for i, p in enumerate(output.splitlines()): + n = os.path.relpath(p, 'build') + url_dev = f'http://localhost:3000/{n}' + url_ref = f'https://docs.wire.com/{n}' + img_basename = sanitize_name(n) + '_' + str(i) + + try: + print(f'./screenshots/{i:03}-{img_basename}_dev.png') + driver.get(url_dev) + driver.get_full_page_screenshot_as_file(f'./screenshots/{i:03}-{img_basename}_dev.png') + print(url_ref) + driver.get(url_ref) + driver.get_full_page_screenshot_as_file(f'./screenshots/{i:03}-{img_basename}_ref.png') + except: + pass + +driver.close() + + + +# assert "Python" in driver.title +# elem = driver.find_element(By.NAME, "q") +# elem.clear() +# elem.send_keys("pycon") +# elem.send_keys(Keys.RETURN) +# assert "No results found." not in driver.page_source +# diff --git a/docs/convert/shell.nix b/docs/convert/shell.nix new file mode 100644 index 0000000000..130c456c6d --- /dev/null +++ b/docs/convert/shell.nix @@ -0,0 +1,17 @@ +{ pkgs ? import {} }: +(pkgs.buildFHSUserEnv { + name = "pipzone"; + targetPkgs = pkgs: (with pkgs; [ + python3 + python3Packages.pip + python3Packages.virtualenv + ]); + runScript = "bash"; +}).env + +# then +# virtualenv venv +# pip install rst-to-myst +# Fix this bug locally: https://github.com/executablebooks/rst-to-myst/issues/49 +# pip install sphinx-reredirects +# pip install sphinx-multiversion diff --git a/docs/src/conf.py b/docs/src/conf.py index e7c36d04e6..1d2c7fa490 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -128,6 +128,5 @@ "security-responses/log4shell": "2021-12-15_log4shell.html", "security-responses/cve-2021-44521": "2022-02-21_cve-2021-44521.html", "security-responses/2022-05_website_outage": "2022-05-23_website_outage.html", - "how-to/single-sign-on/index": "../../understand/single-sign-on/main.html#setting-up-sso-externally", - "how-to/scim/index": "../../understand/single-sign-on/main.html#user-provisioning", + "how-to/scim/index": "../../understand/single-sign-on/main.html#user-provisioning" } diff --git a/docs/src/developer/developer/index.md b/docs/src/developer/developer/index.md new file mode 100644 index 0000000000..77e35760cf --- /dev/null +++ b/docs/src/developer/developer/index.md @@ -0,0 +1,10 @@ +# Developer + +```{toctree} +:caption: 'Contents:' +:glob: true +:numbered: true +:titlesonly: true + +** +``` diff --git a/docs/src/developer/developer/index.rst b/docs/src/developer/developer/index.rst deleted file mode 100644 index a8fefaa770..0000000000 --- a/docs/src/developer/developer/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -Developer -========= - -.. toctree:: - :titlesonly: - :numbered: - :caption: Contents: - :glob: - - ** diff --git a/docs/src/developer/index.rst b/docs/src/developer/index.md similarity index 52% rename from docs/src/developer/index.rst rename to docs/src/developer/index.md index b48dbecae0..59cf4fd92e 100644 --- a/docs/src/developer/index.rst +++ b/docs/src/developer/index.md @@ -1,19 +1,19 @@ -Notes for developers -==================== +# Notes for developers -If you are an on-premise operator (administrating your own self-hosted installation of wire-server), you may want to go back to `docs.wire.com `_ and ignore this section of the docs. +If you are an on-premise operator (administrating your own self-hosted installation of wire-server), you may want to go back to [docs.wire.com](https://docs.wire.com/) and ignore this section of the docs. -If you are a wire end-user, please check out our `support pages `_. +If you are a wire end-user, please check out our [support pages](https://support.wire.com/). What you need to know as a user of the Wire backend: concepts, features, and API. We want to keep these up to date. They could benefit from some re-ordering, and they are far from complete, but we hope they will still help you. -.. toctree:: - :titlesonly: - :caption: Contents: - :glob: +```{toctree} +:caption: 'Contents:' +:glob: true +:titlesonly: true - developer/index.rst - reference/index.rst +developer/index.rst +reference/index.rst +``` diff --git a/docs/src/developer/reference/index.md b/docs/src/developer/reference/index.md new file mode 100644 index 0000000000..4b6e82f195 --- /dev/null +++ b/docs/src/developer/reference/index.md @@ -0,0 +1,10 @@ +# Reference + +```{toctree} +:caption: 'Contents:' +:glob: true +:numbered: true +:titlesonly: true + +** +``` diff --git a/docs/src/developer/reference/index.rst b/docs/src/developer/reference/index.rst deleted file mode 100644 index 1eb9feedba..0000000000 --- a/docs/src/developer/reference/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -Reference -========= - -.. toctree:: - :titlesonly: - :numbered: - :caption: Contents: - :glob: - - ** diff --git a/docs/src/developer/reference/spar-braindump.md b/docs/src/developer/reference/spar-braindump.md index f32532108b..dcee5847e9 100644 --- a/docs/src/developer/reference/spar-braindump.md +++ b/docs/src/developer/reference/spar-braindump.md @@ -113,7 +113,7 @@ export IDP_ID=... Copy the new metadata file to one of your spar instances. -Ssh into it. If you can't, [the sso docs](../../understand/single-sign-on/main.rst) explain how you can create a +Ssh into it. If you can't, [the sso docs](../../how-to/single-sign-on/understand/main.rst) explain how you can create a bearer token if you have the admin's login credentials. If you follow that approach, you need to replace all mentions of `-H'Z-User ...'` with `-H'Authorization: Bearer ...'` in the following, and you won't need diff --git a/docs/src/how-to/administrate/cassandra.md b/docs/src/how-to/administrate/cassandra.md new file mode 100644 index 0000000000..c75439d626 --- /dev/null +++ b/docs/src/how-to/administrate/cassandra.md @@ -0,0 +1,63 @@ +# Cassandra + +```{eval-rst} +.. include:: includes/intro.rst +``` + +This section only covers the bare minimum, for more information, see the [cassandra +documentation](https://cassandra.apache.org/doc/latest/) + +(check-the-health-of-a-cassandra-node)= + +## Check the health of a Cassandra node + +To check the health of a Cassandra node, run the following command: + +```sh +ssh /opt/cassandra/bin/nodetool status +``` + +or if you are running a newer version of wire-server (altough it should be backwards compatibile) + +```sh +ssh /opt/cassandra/bin/nodetool -h ::FFFF:127.0.0.1 status +``` + +You should see a list of nodes like this: + +```sh +Datacenter: datacenter1 +======================= +Status=Up/Down +|/ State=Normal/Leaving/Joining/Moving +-- Address Load Tokens Owns (effective) Host ID Rack +UN 192.168.220.13 9.51MiB 256 100.0% 3dba71c8-eea7-4e35-8f35-4386e7944894 rack1 +UN 192.168.220.23 9.53MiB 256 100.0% 3af56f1f-7685-4b5b-b73f-efdaa371e96e rack1 +UN 192.168.220.33 9.55MiB 256 100.0% RANDOMLY-MADE-UUID-GOES-INTHISPLACE! rack1 +``` + +A `UN` at the begginng of the line, refers to a node that is `Up` and `Normal`. + +## How to inspect tables and data manually + +```sh +cqlsh +# from the cqlsh shell +describe keyspaces +use ; +describe tables; +select * from WHERE = LIMIT 10; +``` + +## How to rolling-restart a cassandra cluster + +For maintenance you may need to restart the cluster. + +On each server one by one: + +1. check your cluster is healthy: `nodetool status` or `nodetool -h ::FFFF:127.0.0.1 status` (in newer versions) +2. `nodetool drain && systemctl stop cassandra` (to stop accepting writes and flush data to disk; then stop the process) +3. do any operation you need, if any +4. Start the cassandra daemon process: `systemctl start cassandra` +5. Wait for your cluster to be healthy again. +6. Do the same on the next server. diff --git a/docs/src/how-to/administrate/cassandra.rst b/docs/src/how-to/administrate/cassandra.rst deleted file mode 100644 index 180a8f2a8c..0000000000 --- a/docs/src/how-to/administrate/cassandra.rst +++ /dev/null @@ -1,65 +0,0 @@ -Cassandra --------------------------- - -.. include:: includes/intro.rst - -This section only covers the bare minimum, for more information, see the `cassandra -documentation `__ - -Check the health of a Cassandra node -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To check the health of a Cassandra node, run the following command: - -.. code:: sh - - ssh /opt/cassandra/bin/nodetool status - -or if you are running a newer version of wire-server (altough it should be backwards compatibile) - -.. code:: sh - - ssh /opt/cassandra/bin/nodetool -h ::FFFF:127.0.0.1 status - -You should see a list of nodes like this: - -.. code:: sh - - Datacenter: datacenter1 - ======================= - Status=Up/Down - |/ State=Normal/Leaving/Joining/Moving - -- Address Load Tokens Owns (effective) Host ID Rack - UN 192.168.220.13 9.51MiB 256 100.0% 3dba71c8-eea7-4e35-8f35-4386e7944894 rack1 - UN 192.168.220.23 9.53MiB 256 100.0% 3af56f1f-7685-4b5b-b73f-efdaa371e96e rack1 - UN 192.168.220.33 9.55MiB 256 100.0% RANDOMLY-MADE-UUID-GOES-INTHISPLACE! rack1 - -A ``UN`` at the begginng of the line, refers to a node that is ``Up`` and ``Normal``. - -How to inspect tables and data manually -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: sh - - cqlsh - # from the cqlsh shell - describe keyspaces - use ; - describe tables; - select * from WHERE = LIMIT 10; - -How to rolling-restart a cassandra cluster -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For maintenance you may need to restart the cluster. - -On each server one by one: - -1. check your cluster is healthy: ``nodetool status`` or ``nodetool -h ::FFFF:127.0.0.1 status`` (in newer versions) -2. ``nodetool drain && systemctl stop cassandra`` (to stop accepting writes and flush data to disk; then stop the process) -3. do any operation you need, if any -4. Start the cassandra daemon process: ``systemctl start cassandra`` -5. Wait for your cluster to be healthy again. -6. Do the same on the next server. - - diff --git a/docs/src/how-to/administrate/elasticsearch.md b/docs/src/how-to/administrate/elasticsearch.md new file mode 100644 index 0000000000..f128a0c1d6 --- /dev/null +++ b/docs/src/how-to/administrate/elasticsearch.md @@ -0,0 +1,127 @@ +# Elasticsearch + +```{eval-rst} +.. include:: includes/intro.rst +``` + +For more information, see the [elasticsearch +documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html) + +(restart-elasticsearch)= + +## How to rolling-restart an elasticsearch cluster + +For maintenance you may need to restart the cluster. + +On each server one by one: + +1. check your cluster is healthy (see above) +2. stop shard allocation: + +```sh +ES_IP= +curl -sSf -XPUT http://localhost:9200/_cluster/settings -H 'Content-Type: application/json' -d "{ \"transient\" : {\"cluster.routing.allocation.exclude._ip\": \"$ES_IP\" }}"; echo; +``` + +You should expect some output like this: + +```sh +{"acknowledged":true,"persistent":{},"transient":{"cluster":{"routing":{"allocation":{"exclude":{"_ip":""}}}}}} +``` + +3. Stop the elasticsearch daemon process: `systemctl stop elasticsearch` +4. do any operation you need, if any +5. Start the elasticsearch daemon process: `systemctl start elasticsearch` +6. re-enable shard allocation: + +```sh +curl -sSf -XPUT http://localhost:9200/_cluster/settings -H 'Content-Type: application/json' -d "{ \"transient\" : {\"cluster.routing.allocation.exclude._ip\": null }}"; echo; +``` + +You should expect some output like this from the above command: + +```sh +{"acknowledged":true,"persistent":{},"transient":{}} +``` + +6. Wait for your cluster to be healthy again. +7. Do the same on the next server. + +## How to manually look into what is stored in elasticsearch + +See also the elasticsearch sections in {ref}`investigative-tasks`. + +(check-the-health-of-an-elasticsearch-node)= + +## Check the health of an elasticsearch node + +To check the health of an elasticsearch node, run the following command: + +```sh +ssh curl localhost:9200/_cat/health +``` + +You should see output looking like this: + +``` +1630250355 15:18:55 elasticsearch-directory green 3 3 17 6 0 0 0 - 100.0% +``` + +Here, the `green` denotes good node health, and the `3 3` denotes 3 running nodes. + +## Check cluster health + +This is the command to check the health of the entire cluster: + +```sh +ssh curl 'http://localhost:9200/_cluster/health?pretty' +``` + +## List cluster nodes + +This is the command to list the nodes in the cluster: + +```sh +ssh curl 'http://localhost:9200/_cat/nodes?v&h=id,ip,name' +``` + +## Troubleshooting + +Description: +**ES nodes ran out of disk space** and error message says: `"blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];"` + +Solution: + +1. Connect to the node: + +```sh +ssh +``` + +2. Clean up disk (e.g. `apt autoremove` on all nodes), then restart machines and/or the elasticsearch process + +```sh +sudo apt autoremove +sudo reboot +``` + +As always make sure you {ref}`check the health of the process `. before and after the reboot. + +3. Get the elastichsearch cluster out of *read-only* mode, run: + +```sh +curl -X PUT -H 'Content-Type: application/json' http://localhost:9200/_all/_settings -d '{"index.blocks.read_only_allow_delete": null}' +``` + +4. Trigger reindexing: From a kubernetes machine, in one terminal: + +```sh +# The following depends on your namespace where you installed wire-server. By default the namespace is called 'wire'. +kubectl --namespace wire port-forward svc/brig 9999:8080 +``` + +And in a second terminal trigger the reindex: + +```sh +curl -v -X POST localhost:9999/i/index/reindex +``` diff --git a/docs/src/how-to/administrate/elasticsearch.rst b/docs/src/how-to/administrate/elasticsearch.rst deleted file mode 100644 index 3a101a7645..0000000000 --- a/docs/src/how-to/administrate/elasticsearch.rst +++ /dev/null @@ -1,134 +0,0 @@ -Elasticsearch ------------------------------- - -.. include:: includes/intro.rst - -For more information, see the `elasticsearch -documentation `__ - - -.. _restart-elasticsearch: - -How to rolling-restart an elasticsearch cluster -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For maintenance you may need to restart the cluster. - -On each server one by one: - -1. check your cluster is healthy (see above) -2. stop shard allocation: - -.. code:: sh - - ES_IP= - curl -sSf -XPUT http://localhost:9200/_cluster/settings -H 'Content-Type: application/json' -d "{ \"transient\" : {\"cluster.routing.allocation.exclude._ip\": \"$ES_IP\" }}"; echo; - -You should expect some output like this: - -.. code:: sh - - {"acknowledged":true,"persistent":{},"transient":{"cluster":{"routing":{"allocation":{"exclude":{"_ip":""}}}}}} - -3. Stop the elasticsearch daemon process: ``systemctl stop elasticsearch`` -4. do any operation you need, if any -5. Start the elasticsearch daemon process: ``systemctl start elasticsearch`` -6. re-enable shard allocation: - -.. code:: sh - - curl -sSf -XPUT http://localhost:9200/_cluster/settings -H 'Content-Type: application/json' -d "{ \"transient\" : {\"cluster.routing.allocation.exclude._ip\": null }}"; echo; - -You should expect some output like this from the above command: - -.. code:: sh - - {"acknowledged":true,"persistent":{},"transient":{}} - -6. Wait for your cluster to be healthy again. -7. Do the same on the next server. - -How to manually look into what is stored in elasticsearch -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -See also the elasticsearch sections in :ref:`investigative_tasks`. - - -Check the health of an elasticsearch node -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To check the health of an elasticsearch node, run the following command: - -.. code:: sh - - ssh curl localhost:9200/_cat/health - -You should see output looking like this: - -.. code:: - - 1630250355 15:18:55 elasticsearch-directory green 3 3 17 6 0 0 0 - 100.0% - -Here, the ``green`` denotes good node health, and the ``3 3`` denotes 3 running nodes. - -Check cluster health -~~~~~~~~~~~~~~~~~~~~ - -This is the command to check the health of the entire cluster: - -.. code:: sh - - ssh curl 'http://localhost:9200/_cluster/health?pretty' - - -List cluster nodes -~~~~~~~~~~~~~~~~~~ - -This is the command to list the nodes in the cluster: - -.. code:: sh - - ssh curl 'http://localhost:9200/_cat/nodes?v&h=id,ip,name' - - -Troubleshooting -~~~~~~~~~~~~~~~ - -Description: -**ES nodes ran out of disk space** and error message says: ``"blocked by: [FORBIDDEN/12/index read-only / allow delete (api)];"`` - -Solution: - -1. Connect to the node: - -.. code:: sh - - ssh - -2. Clean up disk (e.g. ``apt autoremove`` on all nodes), then restart machines and/or the elasticsearch process - -.. code:: sh - - sudo apt autoremove - sudo reboot - -As always, and as explained in the `operations/procedures page `__, make sure you `check the health of the process `__. before and after the reboot. - -3. Get the elastichsearch cluster out of *read-only* mode, run: - -.. code:: sh - - curl -X PUT -H 'Content-Type: application/json' http://localhost:9200/_all/_settings -d '{"index.blocks.read_only_allow_delete": null}' - -4. Trigger reindexing: From a kubernetes machine, in one terminal: - -.. code:: sh - - # The following depends on your namespace where you installed wire-server. By default the namespace is called 'wire'. - kubectl --namespace wire port-forward svc/brig 9999:8080 - -And in a second terminal trigger the reindex: - -.. code:: sh - - curl -v -X POST localhost:9999/i/index/reindex diff --git a/docs/src/how-to/administrate/etcd.md b/docs/src/how-to/administrate/etcd.md new file mode 100644 index 0000000000..a18c801f87 --- /dev/null +++ b/docs/src/how-to/administrate/etcd.md @@ -0,0 +1,261 @@ +# Etcd + +```{eval-rst} +.. include:: includes/intro.rst +``` + +This section only covers the bare minimum, for more information, see the [etcd documentation](https://etcd.io/) + +(how-to-see-cluster-health)= + +## How to see cluster health + +If the file `/usr/local/bin/etcd-health.sh` is available, you can run + +```sh +etcd-health.sh +``` + +which should produce an output similar to: + +``` +Cluster-Endpoints: https://127.0.0.1:2379 +cURL Command: curl -X GET https://127.0.0.1:2379/v2/members +member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 +member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 +member e767162297c84b1e is healthy: got healthy result from https://10.10.1.12:2379 +cluster is healthy +``` + +If that helper file is not available, create it with the following contents: + +```bash +#!/usr/bin/env bash + +HOST=$(hostname) + +etcdctl --endpoints https://127.0.0.1:2379 --ca-file=/etc/ssl/etcd/ssl/ca.pem --cert-file=/etc/ssl/etcd/ssl/member-$HOST.pem --key-file=/etc/ssl/etcd/ssl/member-$HOST-key.pem --debug cluster-health +``` + +and then make it executable: `chmod +x /usr/local/bin/etcd-health.sh` + +## How to inspect tables and data manually + +```sh +TODO +``` + +(how-to-rolling-restart-an-etcd-cluster)= + +## How to rolling-restart an etcd cluster + +Etcd is a consistent and partition tolerant key-value store. This means that +Etcd nodes can be restarted (one by one) with no impact to the consistency of +data, but there might a small time in which the database can not process +writes. Etcd has a designated leader which decides ordering of events (and thus +writes) in the cluster. When the leader crashes, a leadership election takes +place. During the leadership election, the cluster might be briefly +unavailable for writes. Writes during this period are queued up until a new +leader is elected. Any writes that were happening during the crash of the +leader that were not acknowledged by the leader and the followers yet will be +'lost'. The client that performed this write will experience this as a write +timeout. (Source: ). Client +applications (like kubernetes) are expected to deal with this failure scenario +gracefully. + +Etcd can be restarted in a rolling fashion, by cleanly shutting down and +starting up etcd servers one by one. In Etcd 3.1 and up, when the leader is +cleanly shut down, it will hand over leadership gracefully to another node, +which will minimize the impact of write-availability as election time is +reduced. (Source : +) +Restarting follower nodes has no impact to availability. + +Etcd does load-balancing between servrvers on the client-side. This means that +if a server you were talking to is being restarted, etcd will transparently +redirect the request to another server. It's is thus safe to shut them down at +any point. + +Now to perform a rolling restart of the cluster, do the following steps: + +1. Check your cluster is healthy (see above) +2. Stop the process with `systemctl stop etcd` (this should be safe since etcd clients retry their operation if one endpoint becomes unavailable, see [this page](https://etcd.io/docs/v3.3.12/learning/client-architecture/)) +3. Do any operation you need, if any. Like rebooting +4. `systemctl start etcd` +5. Wait for your cluster to be healthy again. +6. Do the same on the next server. + +*For more details please refer to the official documentation:* [Replacing a failed etcd member](https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#replacing-a-failed-etcd-member) + +(etcd-backup-and-restore)= + +## Backing up and restoring + +Though as long as quorum is maintained in etcd there will be no dataloss, it is +still good to prepare for the worst. If a disaster takes out too many nodes, then +you might have to restore from an old backup. + +Luckily, etcd can take periodic snapshots of your cluster and these can be used +in cases of disaster recovery. Information about how to do snapshots and +restores can be found here: + + +*For more details please refer to the official documentation:* [Backing up an etcd cluster](https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#backing-up-an-etcd-cluster) + +## Troubleshooting + +### How to recover from a single unhealthy etcd node after virtual machine snapshot restore + +After restoring an etcd machine from an earlier snapshot of the machine disk, etcd members may become unable to join. + +Symptoms: That etcd process is unable to start and crashes, and other etcd nodes can't reach it: + +``` +failed to check the health of member e767162297c84b1e on https://10.10.1.12:2379: Get https://10.10.1.12:2379/health: dial tcp 10.10.1.12:2379: getsockopt: connection refused +member e767162297c84b1e is unreachable: [https://10.10.1.12:2379] are all unreachable +``` + +Logs from the crashing etcd: + +``` +(...) +Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.691409 I | raft: e767162297c84b1e [term: 28] received a MsgHeartbeat message with higher term from cca4e6f315097b3b [term: 30] +Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.691620 I | raft: e767162297c84b1e became follower at term 30 +Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.692423 C | raft: tocommit(16152654) is out of range [lastIndex(16061986)]. Was the raft log corrupted, truncated, or lost? +Sep 25 09:27:05 node2 etcd[20288]: panic: tocommit(16152654) is out of range [lastIndex(16061986)]. Was the raft log corrupted, truncated, or lost? +Sep 25 09:27:05 node2 etcd[20288]: goroutine 90 [running]: +(...) +``` + +Etcd will refuse nodes that run behind to join the cluster. If a node has +committed to a certain version of the raft log, it is expected not to jump back +in time after that. In this scenario, we turned an etcd server off, made a +snapshot of the virtual machine, brought it back online, and then restored the +snapshot. What went wrong is is that if you bring up a VM snapshot, it means +the etcd node will now have an older raft log than it had before; even though +it already gossiped to all other nodes that it has knowledge of newer entries. + +As a safety precaution, the other nodes will reject the node that is travelling +back in time, to avoid data corruption. A node could get corrupted for other +reasons as well. Perhaps a disk is faulty and is serving wrong data. Either +way, if you end up in a scenario where a node is unhealthy and will refuse to +rejoin the cluster, it is time to do some operations to get the cluster back in +a healthy state. + +It is not recommended to restore an etcd node from a vm snapshot, as that will +cause these kind of time-travelling behaviours which will make the node +unhealthy. To recover from this situation anyway, +I quote from the etcdv2 admin guide + +> If a member’s data directory is ever lost or corrupted then the user should +> remove the etcd member from the cluster using etcdctl tool. A user should +> avoid restarting an etcd member with a data directory from an out-of-date +> backup. Using an out-of-date data directory can lead to inconsistency as the +> member had agreed to store information via raft then re-joins saying it +> needs that information again. For maximum safety, if an etcd member suffers +> any sort of data corruption or loss, it must be removed from the cluster. +> Once removed the member can be re-added with an empty data directory. + +Note that this piece of documentation is from etcdv2 and not etcdv3. However +the etcdv3 docs describe a similar procedure here + + +The procedure to remove and add a member is documented here: + + +It is also documented in the kubernetes documentation: + + +So following the above guides step by step, we can recover our cluster to be +healthy again. + +First let us make sure our broken member is stopped by runnning this on `node`: + +```sh +systemctl stop etcd +``` + +Now from a healthy node, e.g. `node0` remove the broken node + +```sh +etcdctl3.sh member remove e767162297c84b1e +``` + +And we expect the output to be something like + +```sh +Member e767162297c84b1e removed from cluster 432c10551aa096af +``` + +By removing the member from the cluster, you signal the other nodes to not +expect it to come back with the right state. It will be considered dead and +removed from the peers. This will allow the node to come up with an empty data +directory and it not getting kicked out of the cluster. The cluster should now +be healthy, but only have 2 members, and so it is not to resistent to crashes +at the moment! As we can see if we run the health check from a healthy node. + +```sh +etcd-health.sh +``` + +And we expect only two nodes to be in the cluster: + +``` +Cluster-Endpoints: https://127.0.0.1:2379 +cURL Command: curl -X GET https://127.0.0.1:2379/v2/members +member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 +member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 +cluster is healthy +``` + +Now from a healthy node, re-add the node you just removed. Make sure +to replace the IP in the snippet below with the IP of the node you just removed. + +```sh +etcdctl3.sh member add etcd_2 --peer-urls https://10.10.1.12:2380 +``` + +And it should report that it has been added: + +``` +Member e13b1d076b2f9344 added to cluster 432c10551aa096af + +ETCD_NAME="etcd_2" +ETCD_INITIAL_CLUSTER="etcd_1=https://10.10.1.11:2380,etcd_0=https://10.10.1.10:2380,etcd_2=https://10.10.1.12:2380" +ETCD_INITIAL_CLUSTER_STATE="existing" +``` + +it should now be in the list as "unstarted" instead of it not being in the list at all. + +```sh +etcdctl3.sh member list + + +7c37f7dc10558fae, started, etcd_1, https://10.10.1.11:2380, https://10.10.1.11:2379 +cca4e6f315097b3b, started, etcd_0, https://10.10.1.10:2380, https://10.10.1.10:2379 +e13b1d076b2f9344, unstarted, , https://10.10.1.12:2380, +``` + +Now on the broken node, remove the on-disk state, which was corrupted, and start etcd + +```sh +mv /var/lib/etcd /var/lib/etcd.bak +sudo systemctl start etcd +``` + +If we run the health check now, the cluster should report its healthy now again. + +```sh +etcd-health.sh +``` + +And indeed it outputs so: + +``` +Cluster-Endpoints: https://127.0.0.1:2379 +cURL Command: curl -X GET https://127.0.0.1:2379/v2/members +member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 +member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 +member e13b1d076b2f9344 is healthy: got healthy result from https://10.10.1.12:2379 +cluster is healthy +``` diff --git a/docs/src/how-to/administrate/etcd.rst b/docs/src/how-to/administrate/etcd.rst deleted file mode 100644 index 47bce63d70..0000000000 --- a/docs/src/how-to/administrate/etcd.rst +++ /dev/null @@ -1,264 +0,0 @@ -Etcd --------------------------- - -.. include:: includes/intro.rst - -This section only covers the bare minimum, for more information, see the `etcd documentation `__ - -How to see cluster health -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If the file `/usr/local/bin/etcd-health.sh` is available, you can run - -.. code:: sh - - etcd-health.sh - -which should produce an output similar to:: - - Cluster-Endpoints: https://127.0.0.1:2379 - cURL Command: curl -X GET https://127.0.0.1:2379/v2/members - member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 - member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 - member e767162297c84b1e is healthy: got healthy result from https://10.10.1.12:2379 - cluster is healthy - -If that helper file is not available, create it with the following contents: - -.. code:: bash - - #!/usr/bin/env bash - - HOST=$(hostname) - - etcdctl --endpoints https://127.0.0.1:2379 --ca-file=/etc/ssl/etcd/ssl/ca.pem --cert-file=/etc/ssl/etcd/ssl/member-$HOST.pem --key-file=/etc/ssl/etcd/ssl/member-$HOST-key.pem --debug cluster-health - -and then make it executable: ``chmod +x /usr/local/bin/etcd-health.sh`` - -How to inspect tables and data manually -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. code:: sh - - TODO - - -.. _how-to-rolling-restart-an-etcd-cluster: - -How to rolling-restart an etcd cluster -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Etcd is a consistent and partition tolerant key-value store. This means that -Etcd nodes can be restarted (one by one) with no impact to the consistency of -data, but there might a small time in which the database can not process -writes. Etcd has a designated leader which decides ordering of events (and thus -writes) in the cluster. When the leader crashes, a leadership election takes -place. During the leadership election, the cluster might be briefly -unavailable for writes. Writes during this period are queued up until a new -leader is elected. Any writes that were happening during the crash of the -leader that were not acknowledged by the leader and the followers yet will be -'lost'. The client that performed this write will experience this as a write -timeout. (Source: https://etcd.io/docs/v3.4.0/op-guide/failures/). Client -applications (like kubernetes) are expected to deal with this failure scenario -gracefully. - -Etcd can be restarted in a rolling fashion, by cleanly shutting down and -starting up etcd servers one by one. In Etcd 3.1 and up, when the leader is -cleanly shut down, it will hand over leadership gracefully to another node, -which will minimize the impact of write-availability as election time is -reduced. (Source : -https://kubernetes.io/blog/2018/12/11/etcd-current-status-and-future-roadmap/) -Restarting follower nodes has no impact to availability. - -Etcd does load-balancing between servrvers on the client-side. This means that -if a server you were talking to is being restarted, etcd will transparently -redirect the request to another server. It's is thus safe to shut them down at -any point. - -Now to perform a rolling restart of the cluster, do the following steps: - -1. Check your cluster is healthy (see above) -2. Stop the process with ``systemctl stop etcd`` (this should be safe since etcd clients retry their operation if one endpoint becomes unavailable, see `this page `__) -3. Do any operation you need, if any. Like rebooting -4. ``systemctl start etcd`` -5. Wait for your cluster to be healthy again. -6. Do the same on the next server. - -*For more details please refer to the official documentation:* `Replacing a failed etcd member `__ - - -.. _etcd_backup-and-restore: - -Backing up and restoring -~~~~~~~~~~~~~~~~~~~~~~~~~ -Though as long as quorum is maintained in etcd there will be no dataloss, it is -still good to prepare for the worst. If a disaster takes out too many nodes, then -you might have to restore from an old backup. - -Luckily, etcd can take periodic snapshots of your cluster and these can be used -in cases of disaster recovery. Information about how to do snapshots and -restores can be found here: -https://github.com/etcd-io/etcd/blob/master/Documentation/op-guide/recovery.md - -*For more details please refer to the official documentation:* `Backing up an etcd cluster `__ - - -Troubleshooting -~~~~~~~~~~~~~~~~~~~~~~~~~~ - - -How to recover from a single unhealthy etcd node after virtual machine snapshot restore -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -After restoring an etcd machine from an earlier snapshot of the machine disk, etcd members may become unable to join. - -Symptoms: That etcd process is unable to start and crashes, and other etcd nodes can't reach it:: - - failed to check the health of member e767162297c84b1e on https://10.10.1.12:2379: Get https://10.10.1.12:2379/health: dial tcp 10.10.1.12:2379: getsockopt: connection refused - member e767162297c84b1e is unreachable: [https://10.10.1.12:2379] are all unreachable - -Logs from the crashing etcd:: - - (...) - Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.691409 I | raft: e767162297c84b1e [term: 28] received a MsgHeartbeat message with higher term from cca4e6f315097b3b [term: 30] - Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.691620 I | raft: e767162297c84b1e became follower at term 30 - Sep 25 09:27:05 node2 etcd[20288]: 2019-09-25 07:27:05.692423 C | raft: tocommit(16152654) is out of range [lastIndex(16061986)]. Was the raft log corrupted, truncated, or lost? - Sep 25 09:27:05 node2 etcd[20288]: panic: tocommit(16152654) is out of range [lastIndex(16061986)]. Was the raft log corrupted, truncated, or lost? - Sep 25 09:27:05 node2 etcd[20288]: goroutine 90 [running]: - (...) - - -Etcd will refuse nodes that run behind to join the cluster. If a node has -committed to a certain version of the raft log, it is expected not to jump back -in time after that. In this scenario, we turned an etcd server off, made a -snapshot of the virtual machine, brought it back online, and then restored the -snapshot. What went wrong is is that if you bring up a VM snapshot, it means -the etcd node will now have an older raft log than it had before; even though -it already gossiped to all other nodes that it has knowledge of newer entries. - -As a safety precaution, the other nodes will reject the node that is travelling -back in time, to avoid data corruption. A node could get corrupted for other -reasons as well. Perhaps a disk is faulty and is serving wrong data. Either -way, if you end up in a scenario where a node is unhealthy and will refuse to -rejoin the cluster, it is time to do some operations to get the cluster back in -a healthy state. - -It is not recommended to restore an etcd node from a vm snapshot, as that will -cause these kind of time-travelling behaviours which will make the node -unhealthy. To recover from this situation anyway, -I quote from the etcdv2 admin guide https://github.com/etcd-io/etcd/blob/master/Documentation/v2/admin_guide.md - - If a member’s data directory is ever lost or corrupted then the user should - remove the etcd member from the cluster using etcdctl tool. A user should - avoid restarting an etcd member with a data directory from an out-of-date - backup. Using an out-of-date data directory can lead to inconsistency as the - member had agreed to store information via raft then re-joins saying it - needs that information again. For maximum safety, if an etcd member suffers - any sort of data corruption or loss, it must be removed from the cluster. - Once removed the member can be re-added with an empty data directory. - - -Note that this piece of documentation is from etcdv2 and not etcdv3. However -the etcdv3 docs describe a similar procedure here -https://github.com/etcd-io/etcd/blob/master/Documentation/op-guide/runtime-configuration.md#replace-a-failed-machine - - -The procedure to remove and add a member is documented here: -https://github.com/etcd-io/etcd/blob/master/Documentation/op-guide/runtime-configuration.md#remove-a-member - -It is also documented in the kubernetes documentation: -https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#replacing-a-failed-etcd-member - -So following the above guides step by step, we can recover our cluster to be -healthy again. - -First let us make sure our broken member is stopped by runnning this on ``node``: - -.. code:: sh - - systemctl stop etcd - -Now from a healthy node, e.g. ``node0`` remove the broken node - -.. code:: sh - - etcdctl3.sh member remove e767162297c84b1e - -And we expect the output to be something like - -.. code:: sh - - Member e767162297c84b1e removed from cluster 432c10551aa096af - - -By removing the member from the cluster, you signal the other nodes to not -expect it to come back with the right state. It will be considered dead and -removed from the peers. This will allow the node to come up with an empty data -directory and it not getting kicked out of the cluster. The cluster should now -be healthy, but only have 2 members, and so it is not to resistent to crashes -at the moment! As we can see if we run the health check from a healthy node. - -.. code:: sh - - etcd-health.sh - -And we expect only two nodes to be in the cluster:: - - Cluster-Endpoints: https://127.0.0.1:2379 - cURL Command: curl -X GET https://127.0.0.1:2379/v2/members - member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 - member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 - cluster is healthy - -Now from a healthy node, re-add the node you just removed. Make sure -to replace the IP in the snippet below with the IP of the node you just removed. - -.. code:: sh - - etcdctl3.sh member add etcd_2 --peer-urls https://10.10.1.12:2380 - -And it should report that it has been added:: - - Member e13b1d076b2f9344 added to cluster 432c10551aa096af - - ETCD_NAME="etcd_2" - ETCD_INITIAL_CLUSTER="etcd_1=https://10.10.1.11:2380,etcd_0=https://10.10.1.10:2380,etcd_2=https://10.10.1.12:2380" - ETCD_INITIAL_CLUSTER_STATE="existing" - - -it should now be in the list as "unstarted" instead of it not being in the list at all. - -.. code:: sh - - etcdctl3.sh member list - - - 7c37f7dc10558fae, started, etcd_1, https://10.10.1.11:2380, https://10.10.1.11:2379 - cca4e6f315097b3b, started, etcd_0, https://10.10.1.10:2380, https://10.10.1.10:2379 - e13b1d076b2f9344, unstarted, , https://10.10.1.12:2380, - - -Now on the broken node, remove the on-disk state, which was corrupted, and start etcd - -.. code:: sh - - mv /var/lib/etcd /var/lib/etcd.bak - sudo systemctl start etcd - -If we run the health check now, the cluster should report its healthy now again. - -.. code:: sh - - etcd-health.sh - -And indeed it outputs so:: - - Cluster-Endpoints: https://127.0.0.1:2379 - cURL Command: curl -X GET https://127.0.0.1:2379/v2/members - member 7c37f7dc10558fae is healthy: got healthy result from https://10.10.1.11:2379 - member cca4e6f315097b3b is healthy: got healthy result from https://10.10.1.10:2379 - member e13b1d076b2f9344 is healthy: got healthy result from https://10.10.1.12:2379 - cluster is healthy - - - diff --git a/docs/src/how-to/administrate/general-linux.md b/docs/src/how-to/administrate/general-linux.md new file mode 100644 index 0000000000..e0f6b694fe --- /dev/null +++ b/docs/src/how-to/administrate/general-linux.md @@ -0,0 +1,67 @@ +# General - Linux + +```{eval-rst} +.. include:: includes/intro.rst +``` + +## Which ports and network interface is my process running on? + +The following shows open TCP ports, and the related processes. + +```sh +sudo netstat -antlp | grep LISTEN +``` + +which may yield output like this: + +```sh +tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1536/sshd +``` + +(how-to-see-tls-certs)= + +## How can I see if my TLS certificates are configured the way I expect? + +```{note} +The following assumes you're querying a server from outside (e.g. your laptop). See the next section if operating on a server from an SSH session. +``` + +You can use openssl to check, with e.g. + +```sh +DOMAIN=example.com +PORT=443 +echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT +``` + +or + +```sh +DOMAIN=example.com +PORT=443 +echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text +``` + +To see only the validity (expiration): + +```sh +DOMAIN=example.com +PORT=443 +echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text | grep Validity -A 2 +``` + +## How can I see if my TLS certificates are configured the way I expect (special case kubernetes from a kubernetes machine) + +When you first SSH to a kubernetes node, depending on the setup, DNS may not resolve, in which case you can use the `-servername` parameter: + +```sh +# the IP of the network interface that kubernetes is listening on. 127.0.0.1 may or may not work depending on the installation. It's one of those from +# ifconfig | grep "inet addr" +IP=1.2.3.4 +# PORT can be 443 or 31773, depending on the installation +PORT=443 +# not the root domain, but one of the 5 subdomains for which kubernetes is serving traffic +DOMAIN=app.example.com + +echo Q | openssl s_client -showcerts -servername $DOMAIN -connect $IP:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text | grep Validity -A 2 +``` diff --git a/docs/src/how-to/administrate/general-linux.rst b/docs/src/how-to/administrate/general-linux.rst deleted file mode 100644 index a2c8d81d1d..0000000000 --- a/docs/src/how-to/administrate/general-linux.rst +++ /dev/null @@ -1,69 +0,0 @@ -General - Linux --------------------------- - -.. include:: includes/intro.rst - -Which ports and network interface is my process running on? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The following shows open TCP ports, and the related processes. - -.. code:: sh - - sudo netstat -antlp | grep LISTEN - -which may yield output like this: - -.. code:: sh - - tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1536/sshd - -.. _how-to-see-tls-certs: - -How can I see if my TLS certificates are configured the way I expect? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. note:: - The following assumes you're querying a server from outside (e.g. your laptop). See the next section if operating on a server from an SSH session. - -You can use openssl to check, with e.g. - -.. code:: sh - - DOMAIN=example.com - PORT=443 - echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT - -or - -.. code:: sh - - DOMAIN=example.com - PORT=443 - echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text - -To see only the validity (expiration): - -.. code:: sh - - DOMAIN=example.com - PORT=443 - echo Q | openssl s_client -showcerts -connect $DOMAIN:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text | grep Validity -A 2 - - -How can I see if my TLS certificates are configured the way I expect (special case kubernetes from a kubernetes machine) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When you first SSH to a kubernetes node, depending on the setup, DNS may not resolve, in which case you can use the ``-servername`` parameter: - -.. code:: sh - - # the IP of the network interface that kubernetes is listening on. 127.0.0.1 may or may not work depending on the installation. It's one of those from - # ifconfig | grep "inet addr" - IP=1.2.3.4 - # PORT can be 443 or 31773, depending on the installation - PORT=443 - # not the root domain, but one of the 5 subdomains for which kubernetes is serving traffic - DOMAIN=app.example.com - - echo Q | openssl s_client -showcerts -servername $DOMAIN -connect $IP:$PORT 2>/dev/null | openssl x509 -inform pem -noout -text | grep Validity -A 2 diff --git a/docs/src/how-to/administrate/index.md b/docs/src/how-to/administrate/index.md new file mode 100644 index 0000000000..5f6dd1ab72 --- /dev/null +++ b/docs/src/how-to/administrate/index.md @@ -0,0 +1,12 @@ +# Administrate components after successful installation + +```{toctree} +:glob: true +:maxdepth: 2 + +Kubernetes + +* +``` + +% TODO: .. include:: administration/redis.rst diff --git a/docs/src/how-to/administrate/index.rst b/docs/src/how-to/administrate/index.rst deleted file mode 100644 index 5995a82a3c..0000000000 --- a/docs/src/how-to/administrate/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -Administrate components after successful installation -===================================================== - -.. toctree:: - :maxdepth: 2 - :glob: - - Kubernetes - - * - -.. - TODO: .. include:: administration/redis.rst - diff --git a/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.md b/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.md new file mode 100644 index 0000000000..ae9323d55f --- /dev/null +++ b/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.md @@ -0,0 +1,10 @@ +# Certificate renewal + +```{toctree} +:glob: true +:maxdepth: 1 + +* +``` + +% diff --git a/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.rst b/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.rst deleted file mode 100644 index b782d3b107..0000000000 --- a/docs/src/how-to/administrate/kubernetes/certificate-renewal/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -Certificate renewal -=================== - -.. toctree:: - :maxdepth: 1 - :glob: - - * - -.. \ No newline at end of file diff --git a/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.md b/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.md new file mode 100644 index 0000000000..316b644cd0 --- /dev/null +++ b/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.md @@ -0,0 +1,241 @@ +# How to renew certificates on kubernetes 1.14.x + +Kubernetes-internal certificates by default (see assumptions) expire after one year. Without renewal, your installation will cease to function. +This page explains how to renew certificates. + +## Assumptions + +- Kubernetes version 1.14.x + +- installed with the help of [Kubespray](https://github.com/kubernetes-sigs/kubespray) + + - This page was tested using kubespray release 2.10 branch from 2019-05-20, i.e. commit `e2f5a9748e4dbfe2fdba7931198b0b5f1f4bdc7e`. + +- setup: 3 scheduled nodes, each hosting master (control plane) + + worker (kubelet) + etcd (cluster state, key-value database) + +*NOTE: due to Kubernetes being installed with Kubespray, the Kubernetes +CAs (expire after 10yr) as well as certificates involved in etcd +communication (expire after 100yr) are not required to be renewed (any +time soon).* + +**Official documentation:** + +- [Certificate Management with kubeadm (v1.14)](https://v1-14.docs.kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-certs/) +- [PKI certificates and requirements (v1.14)](https://v1-14.docs.kubernetes.io/docs/setup/best-practices/certificates/) + +## High-level description + +1. verify current expiration date +2. issue new certificates +3. generate new client configuration (aka kubeconfig file) +4. restart control plane +5. drain node - restart kubelet - uncordon node again +6. repeat 3-5 on all other nodes + +## Step-by-step instructions + +*Please note, that the following instructions may require privileged +execution. So, either switch to a privileged user or prepend following +statements with \`\`sudo\`\`. In any case, it is most likely that every +newly created file has to be owned by \`\`root\`\`, depending on kow +Kubernetes was installed.* + +1. Verify current expiration date on each node + +```bash +export K8S_CERT_DIR=/etc/kubernetes/pki +export ETCD_CERT_DIR=/etc/ssl/etcd/ssl +export KUBELET_CERT_DIR=/var/lib/kubelet/pki + + +for crt in ${K8S_CERT_DIR}/*.crt; do + expirationDate=$(openssl x509 -noout -text -in ${crt} | grep After | sed -e 's/^[[:space:]]*//') + echo "$(basename ${crt}) -- ${expirationDate}" +done + + +for crt in $(ls ${ETCD_CERT_DIR}/*.pem | grep -v 'key'); do + expirationDate=$(openssl x509 -noout -text -in ${crt} | grep After | sed -e 's/^[[:space:]]*//') + echo "$(basename ${crt}) -- ${expirationDate}" +done + +echo "kubelet-client-current.pem -- $(openssl x509 -noout -text -in ${KUBELET_CERT_DIR}/kubelet-client-current.pem | grep After | sed -e 's/^[[:space:]]*//')" +echo "kubelet.crt -- $(openssl x509 -noout -text -in ${KUBELET_CERT_DIR}/kubelet.crt | grep After | sed -e 's/^[[:space:]]*//')" + + +# MASTER: api-server cert +echo -n | openssl s_client -connect localhost:6443 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not +# MASTER: controller-manager cert +echo -n | openssl s_client -connect localhost:10257 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not +# MASTER: scheduler cert +echo -n | openssl s_client -connect localhost:10259 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not + +# WORKER: kubelet cert +echo -n | openssl s_client -connect localhost:10250 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not +``` + +2. Allocate a terminal session on one node and backup existing + certificates & configurations + +```bash +cd /etc/kubernetes + +cp -r ./ssl ./ssl.bkp + +cp admin.conf admin.conf.bkp +cp controller-manager.conf controller-manager.conf.bkp +cp scheduler.conf scheduler.conf.bkp +cp kubelet.conf kubelet.conf.bkp +``` + +3. Renew certificates on that very node + +```bash +kubeadm alpha certs renew apiserver +kubeadm alpha certs renew apiserver-kubelet-client +kubeadm alpha certs renew front-proxy-client +``` + +*Looking at the timestamps of the certificates, it is indicated, that apicerver, kubelet & proxy-client have been +renewed. This can be confirmed, by executing parts of (1).* + +``` +root@kubenode01:/etc/kubernetes$ ls -al ./ssl +total 56 +drwxr-xr-x 2 kube root 4096 Mar 20 17:09 . +drwxr-xr-x 5 kube root 4096 Mar 20 17:08 .. +-rw-r--r-- 1 root root 1517 Mar 20 15:12 apiserver.crt +-rw------- 1 root root 1675 Mar 20 15:12 apiserver.key +-rw-r--r-- 1 root root 1099 Mar 20 15:13 apiserver-kubelet-client.crt +-rw------- 1 root root 1675 Mar 20 15:13 apiserver-kubelet-client.key +-rw-r--r-- 1 root root 1025 Sep 23 14:53 ca.crt +-rw------- 1 root root 1679 Sep 23 14:53 ca.key +-rw-r--r-- 1 root root 1038 Sep 23 14:53 front-proxy-ca.crt +-rw------- 1 root root 1679 Sep 23 14:53 front-proxy-ca.key +-rw-r--r-- 1 root root 1058 Mar 20 15:13 front-proxy-client.crt +-rw------- 1 root root 1675 Mar 20 15:13 front-proxy-client.key +-rw------- 1 root root 1679 Sep 23 14:53 sa.key +-rw------- 1 root root 451 Sep 23 14:53 sa.pub +``` + +4. Based on those renewed certificates, generate new kubeconfig files + +The first command assumes it's being executed on a master node. You may need to swap `masters` with `nodes` in +case you are on a different sort of machines. + +```bash +kubeadm alpha kubeconfig user --org system:masters --client-name kubernetes-admin > /etc/kubernetes/admin.conf +kubeadm alpha kubeconfig user --client-name system:kube-controller-manager > /etc/kubernetes/controller-manager.conf +kubeadm alpha kubeconfig user --client-name system:kube-scheduler > /etc/kubernetes/scheduler.conf +``` + +*Again, check if ownership and permission for these files are the same +as all the others around them.* + +And, in case you are operating the cluster from the current node, you may want to replace the user's kubeconfig. +Afterwards, compare the backup version with the new one, to see if any configuration (e.g. pre-configured *namespace*) +might need to be moved over, too. + +```bash +mv ~/.kube/config ~/.kube/config.bkp +cp /etc/kubernetes/admin.conf ~/.kube/config +chown $(id -u):$(id -g) ~/.kube/config +chmod 770 ~/.kube/config +``` + +5. Now that certificates and configuration files are in place, the + control plane must be restarted. They typically run in containers, so + the easiest way to trigger a restart, is to kill the processes + running in there. Use (1) to verify, that the expiration dates indeed + have been changed. + +```bash +kill -s SIGHUP $(pidof kube-apiserver) +kill -s SIGHUP $(pidof kube-controller-manager) +kill -s SIGHUP $(pidof kube-scheduler) +``` + +6. Make *kubelet* aware of the new certificate + +1) Drain the node + +``` +kubectl drain --delete-local-data --ignore-daemonsets $(hostname) +``` + +2. Stop the kubelet process + +``` +systemctl stop kubelet +``` + +3. Remove old certificates and configuration + +``` +mv /var/lib/kubelet/pki{,old} +mkdir /var/lib/kubelet/pki +``` + +4. Generate new kubeconfig file for the kubelet + +``` +kubeadm alpha kubeconfig user --org system:nodes --client-name system:node:$(hostname) > /etc/kubernetes/kubelet.conf +``` + +5. Start kubelet again + +``` +systemctl start kubelet +``` + +6. \[Optional\] Verify kubelet has recognized certificate rotation + +``` +sleep 5 && systemctl status kubelet +``` + +7. Allow workload to be scheduled again on the node + +``` +kubectl uncordon $(hostname) +``` + +7. Copy certificates over to all the other nodes + +Option A - you can ssh from one kubernetes node to another + +```bash +# set the ip or hostname: +export NODE2=root@ip-or-hostname +export NODE3=... + +scp ./ssl/apiserver.* "${NODE2}:/etc/kubernetes/ssl/" +scp ./ssl/apiserver.* "${NODE3}:/etc/kubernetes/ssl/" + +scp ./ssl/apiserver-kubelet-client.* "${NODE2}:/etc/kubernetes/ssl/" +scp ./ssl/apiserver-kubelet-client.* "${NODE3}:/etc/kubernetes/ssl/" + +scp ./ssl/front-proxy-client.* "${NODE2}:/etc/kubernetes/ssl/" +scp ./ssl/front-proxy-client.* "${NODE3}:/etc/kubernetes/ssl/" +``` + +Option B - copy via local administrator's machine + +```bash +# set the ip or hostname: +export NODE1=root@ip-or-hostname +export NODE2= +export NODE3= + +scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver.*" "${NODE2}:/etc/kubernetes/ssl/" +scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver.*" "${NODE3}:/etc/kubernetes/ssl/" + +scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver-kubelet-client.*" "${NODE2}:/etc/kubernetes/ssl/" +scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver-kubelet-client.*" "${NODE3}:/etc/kubernetes/ssl/" + +scp -3 "${NODE1}:/etc/kubernetes/ssl/front-proxy-client.*" "${NODE2}:/etc/kubernetes/ssl/" +scp -3 "${NODE1}:/etc/kubernetes/ssl/front-proxy-client.*" "${NODE3}:/etc/kubernetes/ssl/" +``` + +8. Continue again with (4) for each node that is left diff --git a/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.rst b/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.rst deleted file mode 100644 index 2db6f4f178..0000000000 --- a/docs/src/how-to/administrate/kubernetes/certificate-renewal/scenario-1_k8s-v1.14-kubespray.rst +++ /dev/null @@ -1,244 +0,0 @@ -How to renew certificates on kubernetes 1.14.x -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Kubernetes-internal certificates by default (see assumptions) expire after one year. Without renewal, your installation will cease to function. -This page explains how to renew certificates. - -Assumptions ------------ - -- Kubernetes version 1.14.x -- installed with the help of `Kubespray `__ - - - This page was tested using kubespray release 2.10 branch from 2019-05-20, i.e. commit ``e2f5a9748e4dbfe2fdba7931198b0b5f1f4bdc7e``. -- setup: 3 scheduled nodes, each hosting master (control plane) + - worker (kubelet) + etcd (cluster state, key-value database) - -*NOTE: due to Kubernetes being installed with Kubespray, the Kubernetes -CAs (expire after 10yr) as well as certificates involved in etcd -communication (expire after 100yr) are not required to be renewed (any -time soon).* - -**Official documentation:** - -* `Certificate Management with kubeadm (v1.14) `__ -* `PKI certificates and requirements (v1.14) `__ - -High-level description ----------------------- - -1. verify current expiration date -2. issue new certificates -3. generate new client configuration (aka kubeconfig file) -4. restart control plane -5. drain node - restart kubelet - uncordon node again -6. repeat 3-5 on all other nodes - -Step-by-step instructions -------------------------- - -*Please note, that the following instructions may require privileged -execution. So, either switch to a privileged user or prepend following -statements with ``sudo``. In any case, it is most likely that every -newly created file has to be owned by ``root``, depending on kow -Kubernetes was installed.* - -1. Verify current expiration date on each node - -.. code:: bash - - - export K8S_CERT_DIR=/etc/kubernetes/pki - export ETCD_CERT_DIR=/etc/ssl/etcd/ssl - export KUBELET_CERT_DIR=/var/lib/kubelet/pki - - - for crt in ${K8S_CERT_DIR}/*.crt; do - expirationDate=$(openssl x509 -noout -text -in ${crt} | grep After | sed -e 's/^[[:space:]]*//') - echo "$(basename ${crt}) -- ${expirationDate}" - done - - - for crt in $(ls ${ETCD_CERT_DIR}/*.pem | grep -v 'key'); do - expirationDate=$(openssl x509 -noout -text -in ${crt} | grep After | sed -e 's/^[[:space:]]*//') - echo "$(basename ${crt}) -- ${expirationDate}" - done - - echo "kubelet-client-current.pem -- $(openssl x509 -noout -text -in ${KUBELET_CERT_DIR}/kubelet-client-current.pem | grep After | sed -e 's/^[[:space:]]*//')" - echo "kubelet.crt -- $(openssl x509 -noout -text -in ${KUBELET_CERT_DIR}/kubelet.crt | grep After | sed -e 's/^[[:space:]]*//')" - - - # MASTER: api-server cert - echo -n | openssl s_client -connect localhost:6443 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not - # MASTER: controller-manager cert - echo -n | openssl s_client -connect localhost:10257 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not - # MASTER: scheduler cert - echo -n | openssl s_client -connect localhost:10259 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not - - # WORKER: kubelet cert - echo -n | openssl s_client -connect localhost:10250 2>&1 | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' | openssl x509 -text -noout | grep Not - -2. Allocate a terminal session on one node and backup existing - certificates & configurations - -.. code:: bash - - cd /etc/kubernetes - - cp -r ./ssl ./ssl.bkp - - cp admin.conf admin.conf.bkp - cp controller-manager.conf controller-manager.conf.bkp - cp scheduler.conf scheduler.conf.bkp - cp kubelet.conf kubelet.conf.bkp - -3. Renew certificates on that very node - -.. code:: bash - - kubeadm alpha certs renew apiserver - kubeadm alpha certs renew apiserver-kubelet-client - kubeadm alpha certs renew front-proxy-client - -*Looking at the timestamps of the certificates, it is indicated, that apicerver, kubelet & proxy-client have been -renewed. This can be confirmed, by executing parts of (1).* - -:: - - root@kubenode01:/etc/kubernetes$ ls -al ./ssl - total 56 - drwxr-xr-x 2 kube root 4096 Mar 20 17:09 . - drwxr-xr-x 5 kube root 4096 Mar 20 17:08 .. - -rw-r--r-- 1 root root 1517 Mar 20 15:12 apiserver.crt - -rw------- 1 root root 1675 Mar 20 15:12 apiserver.key - -rw-r--r-- 1 root root 1099 Mar 20 15:13 apiserver-kubelet-client.crt - -rw------- 1 root root 1675 Mar 20 15:13 apiserver-kubelet-client.key - -rw-r--r-- 1 root root 1025 Sep 23 14:53 ca.crt - -rw------- 1 root root 1679 Sep 23 14:53 ca.key - -rw-r--r-- 1 root root 1038 Sep 23 14:53 front-proxy-ca.crt - -rw------- 1 root root 1679 Sep 23 14:53 front-proxy-ca.key - -rw-r--r-- 1 root root 1058 Mar 20 15:13 front-proxy-client.crt - -rw------- 1 root root 1675 Mar 20 15:13 front-proxy-client.key - -rw------- 1 root root 1679 Sep 23 14:53 sa.key - -rw------- 1 root root 451 Sep 23 14:53 sa.pub - -4. Based on those renewed certificates, generate new kubeconfig files - -The first command assumes it's being executed on a master node. You may need to swap ``masters`` with ``nodes`` in -case you are on a different sort of machines. - -.. code:: bash - - kubeadm alpha kubeconfig user --org system:masters --client-name kubernetes-admin > /etc/kubernetes/admin.conf - kubeadm alpha kubeconfig user --client-name system:kube-controller-manager > /etc/kubernetes/controller-manager.conf - kubeadm alpha kubeconfig user --client-name system:kube-scheduler > /etc/kubernetes/scheduler.conf - -*Again, check if ownership and permission for these files are the same -as all the others around them.* - -And, in case you are operating the cluster from the current node, you may want to replace the user's kubeconfig. -Afterwards, compare the backup version with the new one, to see if any configuration (e.g. pre-configured *namespace*) -might need to be moved over, too. - -.. code:: bash - - mv ~/.kube/config ~/.kube/config.bkp - cp /etc/kubernetes/admin.conf ~/.kube/config - chown $(id -u):$(id -g) ~/.kube/config - chmod 770 ~/.kube/config - -5. Now that certificates and configuration files are in place, the - control plane must be restarted. They typically run in containers, so - the easiest way to trigger a restart, is to kill the processes - running in there. Use (1) to verify, that the expiration dates indeed - have been changed. - -.. code:: bash - - kill -s SIGHUP $(pidof kube-apiserver) - kill -s SIGHUP $(pidof kube-controller-manager) - kill -s SIGHUP $(pidof kube-scheduler) - -6. Make *kubelet* aware of the new certificate - -a) Drain the node - -:: - - kubectl drain --delete-local-data --ignore-daemonsets $(hostname) - -b) Stop the kubelet process - -:: - - systemctl stop kubelet - -c) Remove old certificates and configuration - -:: - - mv /var/lib/kubelet/pki{,old} - mkdir /var/lib/kubelet/pki - -d) Generate new kubeconfig file for the kubelet - -:: - - kubeadm alpha kubeconfig user --org system:nodes --client-name system:node:$(hostname) > /etc/kubernetes/kubelet.conf - -e) Start kubelet again - -:: - - systemctl start kubelet - -f) [Optional] Verify kubelet has recognized certificate rotation - -:: - - sleep 5 && systemctl status kubelet - -g) Allow workload to be scheduled again on the node - -:: - - kubectl uncordon $(hostname) - -7. Copy certificates over to all the other nodes - -Option A - you can ssh from one kubernetes node to another - -.. code:: bash - - # set the ip or hostname: - export NODE2=root@ip-or-hostname - export NODE3=... - - scp ./ssl/apiserver.* "${NODE2}:/etc/kubernetes/ssl/" - scp ./ssl/apiserver.* "${NODE3}:/etc/kubernetes/ssl/" - - scp ./ssl/apiserver-kubelet-client.* "${NODE2}:/etc/kubernetes/ssl/" - scp ./ssl/apiserver-kubelet-client.* "${NODE3}:/etc/kubernetes/ssl/" - - scp ./ssl/front-proxy-client.* "${NODE2}:/etc/kubernetes/ssl/" - scp ./ssl/front-proxy-client.* "${NODE3}:/etc/kubernetes/ssl/" - -Option B - copy via local administrator's machine - -.. code:: bash - - # set the ip or hostname: - export NODE1=root@ip-or-hostname - export NODE2= - export NODE3= - - scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver.*" "${NODE2}:/etc/kubernetes/ssl/" - scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver.*" "${NODE3}:/etc/kubernetes/ssl/" - - scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver-kubelet-client.*" "${NODE2}:/etc/kubernetes/ssl/" - scp -3 "${NODE1}:/etc/kubernetes/ssl/apiserver-kubelet-client.*" "${NODE3}:/etc/kubernetes/ssl/" - - scp -3 "${NODE1}:/etc/kubernetes/ssl/front-proxy-client.*" "${NODE2}:/etc/kubernetes/ssl/" - scp -3 "${NODE1}:/etc/kubernetes/ssl/front-proxy-client.*" "${NODE3}:/etc/kubernetes/ssl/" - -8. Continue again with (4) for each node that is left diff --git a/docs/src/how-to/administrate/kubernetes/index.md b/docs/src/how-to/administrate/kubernetes/index.md new file mode 100644 index 0000000000..cc2c6a0143 --- /dev/null +++ b/docs/src/how-to/administrate/kubernetes/index.md @@ -0,0 +1,20 @@ +# Kubernetes + +```{note} +These are not the official documentations you are looking for. +[This way](https://kubernetes.io/docs/tasks/administer-cluster/) please. + +The content referred below merely contains either some deviation from upstream or +additional information enriched here and there with shortcuts to the official documentation. +``` + +```{toctree} +:glob: true +:maxdepth: 1 + +Certificate renewal +How to restart a machine that is part of a Kubernetes cluster? +How to upgrade Kubernetes? +``` + +% diff --git a/docs/src/how-to/administrate/kubernetes/index.rst b/docs/src/how-to/administrate/kubernetes/index.rst deleted file mode 100644 index 2e6fcd71da..0000000000 --- a/docs/src/how-to/administrate/kubernetes/index.rst +++ /dev/null @@ -1,21 +0,0 @@ -Kubernetes -========== - -.. note:: - - These are not the official documentations you are looking for. - `This way `__ please. - - The content referred below merely contains either some deviation from upstream or - additional information enriched here and there with shortcuts to the official documentation. - - -.. toctree:: - :maxdepth: 1 - :glob: - - Certificate renewal - How to restart a machine that is part of a Kubernetes cluster? - How to upgrade Kubernetes? - -.. diff --git a/docs/src/how-to/administrate/kubernetes/restart-machines/index.md b/docs/src/how-to/administrate/kubernetes/restart-machines/index.md new file mode 100644 index 0000000000..0323efcf2d --- /dev/null +++ b/docs/src/how-to/administrate/kubernetes/restart-machines/index.md @@ -0,0 +1,42 @@ +(restarting-a-machine-in-a-kubernetes-cluster)= + +# Restarting a machine in a Kubernetes cluster + +```{note} +1. Know which kind of machine is going to be restarted + + > 1. control plane (api-server, controllers, etc.) + > 2. node (runs actual workload, e.g. *Brig* or *Webapp*) + > 3. *a* and *b* combined + +2. The kind of machine in question must be deployed redundantly + +3. Take out machines in a rolling fashion (sequentially, one at a time) +``` + +## Control plane + +Depending on whether *etcd* is hosted on the same machine alongside the control plane (common practise), you need +to take its implications into account (see {ref}`How to rolling-restart an etcd cluster `) +when restarting a machine. + +Regardless of where *etcd* is located, before turning off any machine that is part of the control plane, one should +{ref}`back up the cluster state `. + +If a part of the control plane does not run sufficiently redundant, it is advised to prevent any mutating interaction +during the procedure, until the cluster is healthy again. + +```bash +kubectl get nodes +``` + +## Node + +```{rubric} High-level steps: +``` + +1. Drain the node so that all workload is rescheduled on other nodes +2. Restart / Update / Decommission +3. Mark the node as being schedulable again (if not decommissioned) + +*For more details please refer to the official documentation:* [Safely Drain a Node](https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/) diff --git a/docs/src/how-to/administrate/kubernetes/restart-machines/index.rst b/docs/src/how-to/administrate/kubernetes/restart-machines/index.rst deleted file mode 100644 index 4f4a315a93..0000000000 --- a/docs/src/how-to/administrate/kubernetes/restart-machines/index.rst +++ /dev/null @@ -1,45 +0,0 @@ -.. _restarting-a-machine-in-a-kubernetes-cluster: - -Restarting a machine in a Kubernetes cluster -============================================ - -.. note:: - - 1. Know which kind of machine is going to be restarted - - a) control plane (api-server, controllers, etc.) - b) node (runs actual workload, e.g. *Brig* or *Webapp*) - c) *a* and *b* combined - - 2. The kind of machine in question must be deployed redundantly - 3. Take out machines in a rolling fashion (sequentially, one at a time) - - -Control plane -~~~~~~~~~~~~~ - -Depending on whether *etcd* is hosted on the same machine alongside the control plane (common practise), you need -to take its implications into account (see :ref:`How to rolling-restart an etcd cluster `) -when restarting a machine. - -Regardless of where *etcd* is located, before turning off any machine that is part of the control plane, one should -:ref:`back up the cluster state `. - -If a part of the control plane does not run sufficiently redundant, it is advised to prevent any mutating interaction -during the procedure, until the cluster is healthy again. - -.. code:: bash - - kubectl get nodes - - -Node -~~~~ - -.. rubric:: High-level steps: - -1. Drain the node so that all workload is rescheduled on other nodes -2. Restart / Update / Decommission -3. Mark the node as being schedulable again (if not decommissioned) - -*For more details please refer to the official documentation:* `Safely Drain a Node `__ diff --git a/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.md b/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.md new file mode 100644 index 0000000000..739ae7ee2c --- /dev/null +++ b/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.md @@ -0,0 +1,75 @@ +# Upgrading a Kubernetes cluster + +Before upgrading Kubernetes, a couple of aspects should be taken into account: + +- downtime is (not) permitted +- stateful backing services that run outside or on top of Kubernetes + +As a result the following questions arise: + +1. Is an in-place upgrade required (reuse existing machines) or is it possible to + deploy a second cluster right next to the first one and install Wire on top? +2. How was the Kubernetes cluster deployed? + +Depending on the deployment method, the upgrade procedure may vary. It may be reasonable to test +the upgrade in a non-production environment first. +Regardless of the deployment method, it is recommended to {ref}`back up the cluster state +` before starting to upgrade the cluster. Additional background knowledge +can be found in the section about {ref}`restarting a machine in an kubernetes cluster `. + +```{warning} +For an in-place upgrade, it is *NOT* recommended to go straight to the latest Kubernetes +version. Instead, one should upgrade step by step between each minor version. +``` + +## Manually + +Doing an upgrade by hand is cumbersome and error-prone, which is why there are tools and +automation for this procedure. The high-level steps would be: + +1. upgrade the control plane (also see a more detailed [list](https://kubernetes.io/docs/tasks/administer-cluster/cluster-upgrade/#manual-deployments)) + : 1. all *etcd* instances + 2. api-server on each control-plane host + 3. controllers, scheduler, +2. upgrade the nodes (order may vary, depending on whether the kube-components run in containers) + : - kubelet + - kube-proxy + - container runtime +3. then upgrade the clients (`kubectl`, e.g. on workstations or in pipelines) + +*For more details, please refer to the official documentation:* +[Upgrade A Cluster](https://kubernetes.io/docs/tasks/administer-cluster/cluster-upgrade/) + +## Kubespray (Ansible) + +Kubespray comes with a dedicated playbook that should be used to perform the upgrade: +`upgrade-cluster.yml`. Before running the playbook, make sure that the right Kubespray version +is being used. Each Kubespray version supports only a small and sliding window of Kubernetes +versions (check `kube_version` & `kube_version_min_required` in `roles/kubespray-defaults/defaults/main.yaml` +for a given [release version tag](https://github.com/kubernetes-sigs/kubespray/releases)). + +The commands may look similar to this example (assuming Kubernetes v1.18 version installed +with Kubespray 2.14): + +```bash +git clone https://github.com/kubernetes-sigs/kubespray +cd kubespray +git checkout release-2.15 +${EDITOR} roles/kubespray-defaults/defaults/main.yaml + +ansible-playbook -i ./../path/my/inventory-dir -e kube_version=v1.19.7 ./upgrade-cluster.yml +``` + +% TODO: adjust the example showing how to run this with wire-server-deploy a/o the offline toolchain container image + +% TODO: add ref to the part of this documentation that talks about the air-gapped installation + +Kubespray takes care of bringing the new binaries into position on each machine, restarting +the components, and draining/uncordon nodes. + +*For more details please refer to the official documentation:* +[Upgrading Kubernetes in Kubespray](https://kubespray.io/#/docs/upgrades) + +## Kubeadm + +Please refer to the *official documentation:* [Upgrading kubeadm clusters](https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/) diff --git a/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.rst b/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.rst deleted file mode 100644 index 1c09a137f9..0000000000 --- a/docs/src/how-to/administrate/kubernetes/upgrade-cluster/index.rst +++ /dev/null @@ -1,82 +0,0 @@ -Upgrading a Kubernetes cluster -============================== - -Before upgrading Kubernetes, a couple of aspects should be taken into account: - -* downtime is (not) permitted -* stateful backing services that run outside or on top of Kubernetes - -As a result the following questions arise: - -1. Is an in-place upgrade required (reuse existing machines) or is it possible to - deploy a second cluster right next to the first one and install Wire on top? -2. How was the Kubernetes cluster deployed? - -Depending on the deployment method, the upgrade procedure may vary. It may be reasonable to test -the upgrade in a non-production environment first. -Regardless of the deployment method, it is recommended to :ref:`back up the cluster state -` before starting to upgrade the cluster. Additional background knowledge -can be found in the section about :ref:`restarting a machine in an kubernetes cluster `. - - -.. warning:: - - For an in-place upgrade, it is *NOT* recommended to go straight to the latest Kubernetes - version. Instead, one should upgrade step by step between each minor version. - - -Manually -~~~~~~~~ - -Doing an upgrade by hand is cumbersome and error-prone, which is why there are tools and -automation for this procedure. The high-level steps would be: - -1. upgrade the control plane (also see a more detailed `list `__) - a) all *etcd* instances - b) api-server on each control-plane host - c) controllers, scheduler, -2. upgrade the nodes (order may vary, depending on whether the kube-components run in containers) - * kubelet - * kube-proxy - * container runtime -3. then upgrade the clients (``kubectl``, e.g. on workstations or in pipelines) - -*For more details, please refer to the official documentation:* -`Upgrade A Cluster `__ - - -Kubespray (Ansible) -~~~~~~~~~~~~~~~~~~~ - -Kubespray comes with a dedicated playbook that should be used to perform the upgrade: -``upgrade-cluster.yml``. Before running the playbook, make sure that the right Kubespray version -is being used. Each Kubespray version supports only a small and sliding window of Kubernetes -versions (check ``kube_version`` & ``kube_version_min_required`` in ``roles/kubespray-defaults/defaults/main.yaml`` -for a given `release version tag `__). - -The commands may look similar to this example (assuming Kubernetes v1.18 version installed -with Kubespray 2.14): - -.. code:: bash - - git clone https://github.com/kubernetes-sigs/kubespray - cd kubespray - git checkout release-2.15 - ${EDITOR} roles/kubespray-defaults/defaults/main.yaml - - ansible-playbook -i ./../path/my/inventory-dir -e kube_version=v1.19.7 ./upgrade-cluster.yml - -.. TODO: adjust the example showing how to run this with wire-server-deploy a/o the offline toolchain container image -.. TODO: add ref to the part of this documentation that talks about the air-gapped installation - -Kubespray takes care of bringing the new binaries into position on each machine, restarting -the components, and draining/uncordon nodes. - -*For more details please refer to the official documentation:* -`Upgrading Kubernetes in Kubespray `__ - - -Kubeadm -~~~~~~~ - -Please refer to the *official documentation:* `Upgrading kubeadm clusters `__ diff --git a/docs/src/how-to/administrate/minio.rst b/docs/src/how-to/administrate/minio.md similarity index 58% rename from docs/src/how-to/administrate/minio.rst rename to docs/src/how-to/administrate/minio.md index 6953d1355c..1a79ba648d 100644 --- a/docs/src/how-to/administrate/minio.rst +++ b/docs/src/how-to/administrate/minio.md @@ -1,20 +1,18 @@ -Minio ------- +# Minio +```{eval-rst} .. include:: includes/intro.rst +``` -This section only covers the bare minimum, for more information, see the `minio documentation `__ +This section only covers the bare minimum, for more information, see the [minio documentation](https://docs.min.io/) - -Should you be using minio? -=========================== +## Should you be using minio? Minio can be used to emulate an S3-compatible setup. When a native S3-like storage provider is already present in your network or cloud provider, we advise using that instead. -Setting up interaction with Minio -================================= +## Setting up interaction with Minio Minio can be installed on your servers using our provided ansible playbooks. The ansible playbook will also install the minio client and configure it to @@ -25,29 +23,33 @@ minio to run behind a loadbalancer like HAProxy, and configure the Minio client to point to this loadbalancer instead. Our ansible playbooks will also configure the minio client and adds the locally -reachable API under the ``local`` alias:: +reachable API under the `local` alias: - mc config host list +``` +mc config host list +``` -If it is not there, it can be added manually as follows:: +If it is not there, it can be added manually as follows: - mc config host add local http://localhost:9000 +``` +mc config host add local http://localhost:9000 +``` The status of the cluster can be requested by contacting any of the servers. In -our case we will contact the locally running server:: +our case we will contact the locally running server: - mc admin info local +``` +mc admin info local +``` -Minio maintenance -================= +## Minio maintenance There will be times where one wants to take a minio server down for maintenance. One might want to apply security patches, or want to take out a broken disk and replace it with a fixed one. Minio will not tell you the health status of disks. You should have separate alerting and monitoring in place to keep track of hardware health. For example, one could look at -S.M.A.R.T. values that the disks produce with Prometheus `node_exporter -`_ +S.M.A.R.T. values that the disks produce with Prometheus [node_exporter](https://github.com/prometheus-community/node-exporter-textfile-collector-scripts/blob/master/smartmon.sh) Special care has to be taken when restarting Minio nodes, but it should be safe to do so. Minio can operate in read-write mode with (N/2) + 1 instances @@ -62,9 +64,11 @@ interrupted and the user must retry. When you shut down a node, one should take precautions that subsequent API calls are sent to other nodes in the cluster. -To stop a server, type:: +To stop a server, type: - systemctl stop minio-server +``` +systemctl stop minio-server +``` Writes that happen during the server being down will not be synced to the server that is offline. It is important that once you bring the server back @@ -77,32 +81,40 @@ is thus recommended to heal an instance immediately once it is back up; before a restart any other instances. Now that the server is offline, perform any maintenance that you want to do. -Afterwards, restart it with:: +Afterwards, restart it with: - systemctl start minio-server +``` +systemctl start minio-server +``` -Now check:: +Now check: - mc admin info local +``` +mc admin info local +``` to see if the cluster is healthy. Now that the server is back online, it has missed writes that have happened whilst it was offline. Because of this we must heal the cluster now -A heal of the cluster is performed as follows:: +A heal of the cluster is performed as follows: - mc admin heal -r local +``` +mc admin heal -r local +``` -Which will show a result page that looks like this:: +Which will show a result page that looks like this: - ◑ bunny - 0/0 objects; 0 B in 2s - ┌────────┬───┬─────────────────────┐ - │ Green │ 2 │ 66.7% ████████ │ - │ Yellow │ 1 │ 33.3% ████ │ - │ Red │ 0 │ 0.0% │ - │ Grey │ 0 │ 0.0% │ - └────────┴───┴─────────────────────┘ +``` +◑ bunny + 0/0 objects; 0 B in 2s + ┌────────┬───┬─────────────────────┐ + │ Green │ 2 │ 66.7% ████████ │ + │ Yellow │ 1 │ 33.3% ████ │ + │ Red │ 0 │ 0.0% │ + │ Grey │ 0 │ 0.0% │ + └────────┴───┴─────────────────────┘ +``` green - all good yellow - healed partially @@ -110,33 +122,32 @@ red - quorum missing grey - more than quorum number shards are gone, means the object for some reason is not recoverable When there are any yellow items, it usually means that not all servers have seen -the node come up properly again. Running the heal command with the ``--json`` option +the node come up properly again. Running the heal command with the `--json` option will give you more verbose and precise information why the heal only happened partially. -.. code:: json - - { - "after" : { - "online" : 5, - "offline" : 1, - "missing" : 0, - "corrupted" : 0, - "drives" : [ - { - "endpoint" : "http://10.0.0.42:9091/var/lib/minio-server1", - "state" : "offline", - "uuid" : "" - }, - { - "uuid" : "", - "endpoint" : "/var/lib/minio-server1", - "state" : "ok" - } - ], - "color" : "yellow" - } - } - +```json +{ + "after" : { + "online" : 5, + "offline" : 1, + "missing" : 0, + "corrupted" : 0, + "drives" : [ + { + "endpoint" : "http://10.0.0.42:9091/var/lib/minio-server1", + "state" : "offline", + "uuid" : "" + }, + { + "uuid" : "", + "endpoint" : "/var/lib/minio-server1", + "state" : "ok" + } + ], + "color" : "yellow" + } +} +``` In our case, we see that the reason for the partial recovery was that one the server was still considered offline. Rerunning the command yielded @@ -158,97 +169,96 @@ thus important to have good monitoring in place and respond accordingly. Minio itself will auto-heal the cluster every month if the administrator doesn't trigger a heal themselves. - -Rotate root credentials -======================= +## Rotate root credentials In order to change the root credentials, one needs to restart minio once but set with the old and the new credentials at the same time. -If you installed minio with the Ansible, the `role `__ +If you installed minio with the Ansible, the [role](https://github.com/wireapp/ansible-minio) takes care of this. Just change the inventory accordingly and re-apply the role. -For more information, please refer to the *Credentials* section in the `official documentation `__. +For more information, please refer to the *Credentials* section in the [official documentation](https://docs.min.io/docs/minio-server-configuration-guide.html). -Check the health of a MinIO node -================================ +(check-the-health-of-a-minio-node)= -This is the procedure to check a minio node's health. +## Check the health of a MinIO node -First log into the minio server +This is the procedure to check a minio node's health -.. code:: sh +First log into the minio server - ssh +```sh +ssh +``` There, run the following commands: -.. code:: sh - - env $(sudo grep KEY /etc/default/minio-server1 | xargs) bash - export MC_HOST_local="http://$MINIO_ACCESS_KEY:$MINIO_SECRET_KEY@127.0.0.1:9000" - mc admin info local +```sh +env $(sudo grep KEY /etc/default/minio-server1 | xargs) bash +export MC_HOST_local="http://$MINIO_ACCESS_KEY:$MINIO_SECRET_KEY@127.0.0.1:9000" +mc admin info local +``` You should see a result similar to this: -.. code:: sh - - * 192.168.0.12:9092 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - - * 192.168.0.22:9000 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - - * 192.168.0.22:9092 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - - * 192.168.0.32:9000 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - - * 192.168.0.32:9092 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - - * 192.168.0.12:9000 - Uptime: 2 months - Version: 2020-10-28T08:16:50Z - Network: 6/6 OK - Drives: 1/1 OK - -Make sure you see ``Network: 6/6 OK``. +```sh +* 192.168.0.12:9092 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK + +* 192.168.0.22:9000 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK + +* 192.168.0.22:9092 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK + +* 192.168.0.32:9000 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK + +* 192.168.0.32:9092 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK + +* 192.168.0.12:9000 +Uptime: 2 months +Version: 2020-10-28T08:16:50Z +Network: 6/6 OK +Drives: 1/1 OK +``` + +Make sure you see `Network: 6/6 OK`. Reboot the machine with: -.. code:: sh - - sudo reboot +```sh +sudo reboot +``` Then wait at least a minute. If you go to ssh in, and get 'Connection refused', it just means you need to wait a bit longer. -Tip: You can automatically ask SSH to attempt to connect until it is succesful, by using the following command: - -.. code:: sh +Tip: You can automatically ask SSH to attempt to connect until it is succesful, by using the following command: - ssh -o 'ConnectionAttempts 3600' exit +```sh +ssh -o 'ConnectionAttempts 3600' exit +``` Log into minio ( repeat the steps above ), and check again. You should see a very low uptime value on two hosts now. -This is because we install minio 'twice' on each host. \ No newline at end of file +This is because we install minio 'twice' on each host. diff --git a/docs/src/how-to/administrate/operations.md b/docs/src/how-to/administrate/operations.md new file mode 100644 index 0000000000..9a8b8522a6 --- /dev/null +++ b/docs/src/how-to/administrate/operations.md @@ -0,0 +1,139 @@ +# Operational procedures + +This section describes common operations to be performed on operational clusters. + +## Reboot procedures + +The general procedure to reboot a service is as follows: + +- 1. {ref}`Check the health ` of the service. (If the health isn't good search for "troubleshooting" in the documentation. If it is good, move to the next step.) +- 2. Reboot the server the service is running on. +- 3. {ref}`Check the health ` of the service **again**. (If the health isn't good search for "troubleshooting" in the documentation. If it is good, your reboot was succesful.) + +The method for checking health is different for each service type, you can find a list of those methods {ref}`here `. + +The method to reset a service is the same for most services, except for `restund`, for which the procedure is different, and can be found {ref}`here `. + +For other (non-`restund`) services, the procedure is as follows: + +Assuming in this example you are trying to reboot a minio server, follow these steps: + +First, {ref}`check the health ` of the services. + +Second, reboot the services: + +```sh +ssh -t sudo reboot +``` + +Third, wait until the service is up again by trying to connect to it via SSH : + +```sh +ssh -o 'ConnectionAttempts 3600' exit +``` + +(`ConnectionAttempts` will make it so it attempts to connect until the host is actually Up and the connection is succesful) + +Fourth, {ref}`check the health ` of the service again. + +(operations-health-checks)= + +## Health checks + +This is a list of the health-checking procedures currently documented, for different service types: + +- {ref}`MinIO ` +- {ref}`Cassandra ` +- {ref}`Elasticsearch ` +- {ref}`Etcd ` +- {ref}`Restund ` (the health check is explained as part of the reboot procedure). + +To check the health of different services not listed here, see the documentation for that specific project, or ask your Wire contact. + +```{note} +If a service is running inside a Kubernetes pod, checking its health is easy: if the pod is running, it is healthy. A non-healthy pod will stop running, and will be shown as such. +``` + +## Draining pods from a node for maintainance + +You might want to remove («drain») all pods from a specific node/server, so you can do maintainance work on it, without disrupting the entire cluster. + +If you want to do this, you should follow the procudure found at: + +In short, the procedure is essentially: + +First, identify the name of the node you wish to drain. You can list all of the nodes in your cluster with + +```sh +kubectl get nodes +``` + +Next, tell Kubernetes to drain the node: + +```sh +kubectl drain +``` + +Once it returns (without giving an error), you can power down the node (or equivalently, if on a cloud platform, delete the virtual machine backing the node). If you leave the node in the cluster during the maintenance operation, you need to run + +```sh +kubectl uncordon +``` + +afterwards to tell Kubernetes that it can resume scheduling new pods onto the node. + +## Understand release tags + +We have two major release tags that you sometimes want to map on each other: *github*, and *helm chart*. + +Github have a tag of the form `vYYYY-MM-DD`, and the release notes and (some build artefacts) can be found on github, eg., [here](https://github.com/wireapp/wire-server/releases/v2022-01-18). Helm chart tags have the form `N.NNN.0`. The minor version `0` is for the development branch; non-zero values refer to unreleased intermediate states. + +### On the command line + +You can find the github tag for a helm chart tag like this: + +```sh +git tag --points-at v2022-01-18 | sort +``` + +... and the other way around like this: + +```sh +git tag --points-at chart=2.122.0,image=2.122.0 | sort +``` + +Note that the actual tag has the form `chart=,image=`. + +Unfortunately, older releases may have more helm chart tags; you need to find the largest number that has the form `N.NNN.0` from the list yourself. + +A list of all releases can be produced like this: + +```sh +git log --decorate --first-parent origin/master +``` + +If you want to find the + +### In the github UI + +Consult [the changelog](https://github.com/wireapp/wire-server/blob/develop/CHANGELOG.md) +to find the github tag of the release you're interested in (say, +v2022-01-18). + +Visit [the release notes of that release](https://github.com/wireapp/wire-server/releases/v2022-01-18). +Click on the commit hash: + +```{image} operations/fig1.png +``` + +Click on the 3 dots: + +```{image} operations/fig2.png +``` + +Now you can see a (possibly rather long) list of tags, some of then +have the form `chart=N.NNN.0,image=N.NNN.0`. Pick the one with the +largest number. + +```{image} operations/fig3.png +``` diff --git a/docs/src/how-to/administrate/operations.rst b/docs/src/how-to/administrate/operations.rst deleted file mode 100644 index bee240acb1..0000000000 --- a/docs/src/how-to/administrate/operations.rst +++ /dev/null @@ -1,144 +0,0 @@ - -Operational procedures -====================== - -This section describes common operations to be performed on operational clusters. - -Reboot procedures ------------------ - -The general procedure to reboot a service is as follows: - -* 1. `Check the health `__ of the service. (If the health isn't good, move to `troubleshooting `__. If it is good, move to the next step.) -* 2. Reboot the server the service is running on. -* 3. `Check the health `__ of the service **again**. (If the health isn't good, move to `troubleshooting `__. If it is good, your reboot was succesful.) - -The method for checking health is different for each service type, you can find a list of those methods `here `__. - -The method to reset a service is the same for most services, except for ``restund``, for which the procedure is different, and can be found `here `__. - -For other (non-``restund``) services, the procedure is as follows: - -Assuming in this example you are trying to reboot a minio server, follow these steps: - -First, `check the health `__ of the services. - -Second, reboot the services: - -.. code:: sh - - ssh -t sudo reboot - -Third, wait until the service is up again by trying to connect to it via SSH : - -.. code:: sh - - ssh -o 'ConnectionAttempts 3600' exit - -(``ConnectionAttempts`` will make it so it attempts to connect until the host is actually Up and the connection is succesful) - -Fourth, `check the health `__ of the service again. - -Health checks -------------- - -This is a list of the health-checking procedures currently documented, for different service types: - -* `MinIO `__. -* `Cassandra `__. -* `elasticsearch `__. -* `Etcd `__. -* `Restund `__ (the health check is explained as part of the reboot procedure). - -To check the health of different services not listed here, see the documentation for that specific project, or ask your Wire contact. - -.. note:: - - If a service is running inside a Kubernetes pod, checking its health is easy: if the pod is running, it is healthy. A non-healthy pod will stop running, and will be shown as such. - -Draining pods from a node for maintainance ------------------------------------------- - -You might want to remove («drain») all pods from a specific node/server, so you can do maintainance work on it, without disrupting the entire cluster. - -If you want to do this, you should follow the procudure found at: https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/ - -In short, the procedure is essentially: - -First, identify the name of the node you wish to drain. You can list all of the nodes in your cluster with - -.. code:: sh - - kubectl get nodes - -Next, tell Kubernetes to drain the node: - -.. code:: sh - - kubectl drain - -Once it returns (without giving an error), you can power down the node (or equivalently, if on a cloud platform, delete the virtual machine backing the node). If you leave the node in the cluster during the maintenance operation, you need to run - -.. code:: sh - - kubectl uncordon - -afterwards to tell Kubernetes that it can resume scheduling new pods onto the node. - -Understand release tags ------------------------ - -We have two major release tags that you sometimes want to map on each other: *github*, and *helm chart*. - -Github have a tag of the form `vYYYY-MM-DD`, and the release notes and (some build artefacts) can be found on github, eg., `here `__. Helm chart tags have the form `N.NNN.0`. The minor version `0` is for the development branch; non-zero values refer to unreleased intermediate states. - -On the command line -^^^^^^^^^^^^^^^^^^^ - -You can find the github tag for a helm chart tag like this: - -.. code:: sh - - git tag --points-at v2022-01-18 | sort - -... and the other way around like this: - -.. code:: sh - - git tag --points-at chart=2.122.0,image=2.122.0 | sort - -Note that the actual tag has the form `chart=,image=`. - -Unfortunately, older releases may have more helm chart tags; you need to find the largest number that has the form `N.NNN.0` from the list yourself. - -A list of all releases can be produced like this: - -.. code:: sh - - git log --decorate --first-parent origin/master - -If you want to find the - -In the github UI -^^^^^^^^^^^^^^^^ - -Consult `the changelog -`__ -to find the github tag of the release you're interested in (say, -v2022-01-18). - -Visit `the release notes of that release -`__. -Click on the commit hash: - -.. image:: operations/fig1.png - -Click on the 3 dots: - -.. image:: operations/fig2.png - -Now you can see a (possibly rather long) list of tags, some of then -have the form `chart=N.NNN.0,image=N.NNN.0`. Pick the one with the -largest number. - -.. image:: operations/fig3.png diff --git a/docs/src/how-to/administrate/restund.md b/docs/src/how-to/administrate/restund.md new file mode 100644 index 0000000000..86bdd27e6a --- /dev/null +++ b/docs/src/how-to/administrate/restund.md @@ -0,0 +1,293 @@ +# Restund (TURN) + +```{eval-rst} +.. include:: includes/intro.rst +``` + +(allocations)= + +## Wire-Server Configuration + +The wire-server can either serve a static list of TURN servers to the clients or +it can discovery them using DNS SRV Records. + +### Static List + +To configure a static list of TURN servers to use, override +`values/wire-server/values.yaml` like this: + +```yaml +# (...) + +brig: +# (...) + turnStatic: + v1: + # v1 entries can be ignored and are not in use anymore since end of 2018. + v2: + - turn:server1.example.com:3478 # server 1 UDP + - turn:server1.example.com:3478?transport=tcp # server 1 TCP + - turns:server1.example.com:5478?transport=tcp # server 1 TLS + - turn:server2.example.com:3478 # server 2 UDP + - turn:server2.example.com:3478?transport=tcp # server 2 TCP + - turns:server2.example.com:5478?transport=tcp # server 2 TLS + turn: + serversSource: files +``` + +### DNS SRV Records + +To configure wire-server to use DNS SRV records in order to discover TURN +servers, override `values/wire-server/values.yaml` like this: + +```yaml +# (...) + +brig: +# (...) + turn: + serversSource: dns + baseDomain: prod.example.com + discoveryIntervalSeconds: 10 +``` + +When configured like this, the wire-server would look for these 3 SRV records +every 10 seconds: + +1. `_turn._udp.prod.example.com` will be used to discover UDP hostnames and port for all the + turn servers. +2. `_turn._tcp.prod.example.com` will be used to discover the TCP hostnames and port for all + the turn servers. +3. `_turns._tcp.prod.example.com` will be used to discover the TLS hostnames and port for + all the turn servers. + +Entries with weight 0 will be ignored. Example: + +``` +dig +retries=3 +short SRV _turn._udp.prod.example.com + +0 0 3478 turn36.prod.example.com +0 10 3478 turn34..prod.example.com +0 10 3478 turn35.prod.example.com +``` + +At least one of these 3 lookups must succeed for the wire-server to be able to +respond correctly when `GET /calls/config/v2` is called. All successful +responses are served in the result. + +In addition, if there are any clients using the legacy endpoint, `GET +/calls/config`, (all versions of all mobile apps since 2018 no longer use this) they will be served by the servers listed in the +`_turn._udp.prod.example.com` SRV record. This endpoint, however, will not +serve the domain names received inside the SRV record, instead it will serve the +first `A` record that is associated with each domain name in the SRV record. + +## How to see how many people are currently connected to the restund server + +You can see the count of currently ongoing calls (also called "allocations"): + +```sh +echo turnstats | nc -u 127.0.0.1 33000 -q1 | grep allocs_cur | cut -d' ' -f2 +``` + +## How to restart restund (with downtime) + +With downtime, it's very easy: + +``` +systemctl restart restund +``` + +```{warning} +Restarting `restund` means any user that is currently connected to it (i.e. having a call) will lose its audio/video connection. If you wish to have no downtime, check the next section\* +``` + +(rebooting-a-restund-node)= + +## Rebooting a Restund node + +If you want to reboot a restund node, you need to make sure the other restund nodes in the cluster are running, so that services are not interrupted by the reboot. + +```{warning} +This procedure as described here will cause downtime, even if a second restund server is up; and kill any ongoing audio/video calls. The sections further up describe a downtime and a no-downtime procedure. +``` + +Presuming your two restund nodes are called: + +- `restund-1` +- `restund-2` + +To prepare for a reboot of `restund-1`, log into the other restund server (`restund-2`, for example here), and make sure the docker service is running. + +List the running containers, to ensure restund is running, by executing: + +```sh +ssh -t sudo docker container ls +``` + +You should see the following in the results: + +```sh +CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES + quay.io/wire/restund:v0.4.16b1.0.53 22 seconds ago Up 18 seconds restund +``` + +Make sure you see this restund container, and it is running ("Up"). + +If it is not, you need to do troubleshooting work, if it is running, you can move forward and reboot restund-1. + +Now log into the restund server you wish to reboot (`restund-1` in this example), and reboot it + +```sh +ssh -t sudo reboot +``` + +Wait at least a minute for the machine to restart, you can use this command to automatically retry SSH access until it is succesful: + +```sh +ssh -o 'ConnectionAttempts 3600' exit +``` + +Then log into the restund server (`restund-1`, in this example), and make sure the docker service is running: + +```sh +ssh -t sudo docker container ls +``` + +```sh +CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES + quay.io/wire/restund:v0.4.16b1.0.53 22 seconds ago Up 18 seconds restund +``` + +Here again, make sure you see a restund container, and it is running ("Up"). + +If it is, you have succesfully reboot the restund server, and can if you need to apply the same procedure to the other restund servers in your cluster. + +## How to restart restund without having downtime + +For maintenance you may need to restart a restund server. + +1. Remove that restund server you want to restart from the list of advertised nodes, by taking it out of the turn server list that brig advertises: + +Go to the place where you store kubernetes configuration for your wire-server installation. This might be a directory on your admin laptop, or a directory on the kubernetes machine. + +If your override configuration (`values/wire-server/values.yaml`) looks like the following: + +```yaml +# (...) + +brig: +# (...) + turnStatic: + v1: + # v1 entries can be ignored and are not in use anymore since end of 2018. + v2: + - turn:server1.example.com:3478 # server 1 UDP + - turn:server1.example.com:3478?transport=tcp # server 1 TCP + - turns:server1.example.com:5478?transport=tcp # server 1 TLS + - turn:server2.example.com:3478 # server 2 UDP + - turn:server2.example.com:3478?transport=tcp # server 2 TCP + - turns:server2.example.com:5478?transport=tcp # server 2 TLS +``` + +And you want to remove server 1, then change the configuration to read + +```yaml +turnStatic: + v2: + - turn:server2.example.com:3478 # server 2 UDP + - turn:server2.example.com:3478?transport=tcp # server 2 TCP + - turns:server2.example.com:5478?transport=tcp # server 2 TLS +``` + +(or comment out lines by adding a `#` in front of the respective line) + +```yaml +turnStatic: + v2: + #- turn:server1.example.com:3478 # server 1 UDP + #- turn:server1.example.com:3478?transport=tcp # server 1 TCP + #- turns:server1.example.com:5478?transport=tcp # server 1 TLS + - turn:server2.example.com:3478 # server 2 UDP + - turn:server2.example.com:3478?transport=tcp # server 2 TCP + - turns:server2.example.com:5478?transport=tcp # server 2 TLS +``` + +Next, apply these changes to configuration with `./bin/prod-setup.sh` + +You then need to restart the `brig` pods if your code is older than September 2019 (otherwise brig will restart itself automatically): + +```bash +kubectl delete pod -l app=brig +``` + +2. Wait for traffic to drain. This can take up to 12 hours after the configuration change. Wait until current allocations (people connected to the restund server) return 0. See {ref}`allocations`. +3. It's now safe to `systemctl stop restund`, and take any necessary actions. +4. `systemctl start restund` and then add the restund server back to configuration of advertised nodes (see step 1, put the server back). + +## How to renew a certificate for restund + +1. Replace the certificate file on the server (under `/etc/restund/restund.pem` usually), either with ansible or manually. Ensure the new certificate file is a concatenation of your whole certificate chain *and* the private key: + +```text +-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY----- +``` + +2. Restart restund (see sections above) + +## How to check which restund/TURN servers will be used by clients + +The list of turn servers contacted by clients *should* match what you added to your `turnStatic` configuration. But if you'd like to double-check, here's how: + +Terminal one: + +```sh +kubectl port-forward svc/brig 9999:8080 +``` + +Terminal two: + +```sh +UUID=$(cat /proc/sys/kernel/random/uuid) +curl -s -H "Z-User:$UUID" -H "Z-Connection:anything" "http://localhost:9999/calls/config/v2" | json_pp +``` + +May return something like: + +```json +{ + "ice_servers" : [ + { + "credential" : "ASyFLXqbmg8fuK4chJG3S1Qg4L/nnhpkN0/UctdtTFbGW1AcuuAaOqUMDhm9V2w7zKHY6PPMqjhwKZ2neSE78g==", + "urls" : [ + "turn:turn1.example.com:3478" + ], + "username" : "d=1582157904.v=1.k=0.t=s.r=mbzovplogqxbasbf" + }, + { + "credential" : "ZsxEtGWbpUZ3QWxPZtbX6g53HXu6PWfhhUfGNqRBJjrsly5w9IPAsuAWLEOP7fsoSXF13mgSPROXxMYAB/fQ6g==", + "urls" : [ + "turn:turn1.example.com:3478?transport=tcp" + ], + "username" : "d=1582157904.v=1.k=0.t=s.r=jsafnwtgqhfqjvco" + }, + { + "credential" : "ZsxEtGWbpUZ3QWxPZtbX6g53HXu6PWfhhUfGNqRBJjrsly5w9IPAsuAWLEOP7fsoSXF13mgSPROXxMYAB/fQ6g==", + "urls" : [ + "turns:turn1.example.com:5349?transport=tcp" + ], + "username" : "d=1582157904.v=1.k=0.t=s.r=jsafnwtgqhfqjvco" + } + ], + "ttl" : 3600 +} +``` + +In the above case, there is a single server configured to use UDP on port 3478, plain TCP on port 3478, and TLS over TCP on port 5349. The ordering of the list is random and will change on every request made with curl. diff --git a/docs/src/how-to/administrate/restund.rst b/docs/src/how-to/administrate/restund.rst deleted file mode 100644 index 584066ab43..0000000000 --- a/docs/src/how-to/administrate/restund.rst +++ /dev/null @@ -1,301 +0,0 @@ -Restund (TURN) --------------- - -.. include:: includes/intro.rst - -.. _allocations: - -Wire-Server Configuration -~~~~~~~~~~~~~~~~~~~~~~~~~ - -The wire-server can either serve a static list of TURN servers to the clients or -it can discovery them using DNS SRV Records. - -Static List -+++++++++++ - -To configure a static list of TURN servers to use, override -``values/wire-server/values.yaml`` like this: - -.. code:: yaml - - # (...) - - brig: - # (...) - turnStatic: - v1: - # v1 entries can be ignored and are not in use anymore since end of 2018. - v2: - - turn:server1.example.com:3478 # server 1 UDP - - turn:server1.example.com:3478?transport=tcp # server 1 TCP - - turns:server1.example.com:5478?transport=tcp # server 1 TLS - - turn:server2.example.com:3478 # server 2 UDP - - turn:server2.example.com:3478?transport=tcp # server 2 TCP - - turns:server2.example.com:5478?transport=tcp # server 2 TLS - turn: - serversSource: files - -DNS SRV Records -+++++++++++++++ - -To configure wire-server to use DNS SRV records in order to discover TURN -servers, override ``values/wire-server/values.yaml`` like this: - -.. code:: yaml - - # (...) - - brig: - # (...) - turn: - serversSource: dns - baseDomain: prod.example.com - discoveryIntervalSeconds: 10 - -When configured like this, the wire-server would look for these 3 SRV records -every 10 seconds: - -1. ``_turn._udp.prod.example.com`` will be used to discover UDP hostnames and port for all the - turn servers. -2. ``_turn._tcp.prod.example.com`` will be used to discover the TCP hostnames and port for all - the turn servers. -3. ``_turns._tcp.prod.example.com`` will be used to discover the TLS hostnames and port for - all the turn servers. - -Entries with weight 0 will be ignored. Example: - -.. code:: - - dig +retries=3 +short SRV _turn._udp.prod.example.com - - 0 0 3478 turn36.prod.example.com - 0 10 3478 turn34..prod.example.com - 0 10 3478 turn35.prod.example.com - -At least one of these 3 lookups must succeed for the wire-server to be able to -respond correctly when ``GET /calls/config/v2`` is called. All successful -responses are served in the result. - -In addition, if there are any clients using the legacy endpoint, ``GET -/calls/config``, (all versions of all mobile apps since 2018 no longer use this) they will be served by the servers listed in the -``_turn._udp.prod.example.com`` SRV record. This endpoint, however, will not -serve the domain names received inside the SRV record, instead it will serve the -first ``A`` record that is associated with each domain name in the SRV record. - -How to see how many people are currently connected to the restund server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can see the count of currently ongoing calls (also called "allocations"): - -.. code:: sh - - echo turnstats | nc -u 127.0.0.1 33000 -q1 | grep allocs_cur | cut -d' ' -f2 - -How to restart restund (with downtime) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -With downtime, it's very easy:: - - systemctl restart restund - -.. warning:: - - Restarting ``restund`` means any user that is currently connected to it (i.e. having a call) will lose its audio/video connection. If you wish to have no downtime, check the next section* - -Rebooting a Restund node -~~~~~~~~~~~~~~~~~~~~~~~~ - -If you want to reboot a restund node, you need to make sure the other restund nodes in the cluster are running, so that services are not interrupted by the reboot. - -.. warning:: - - This procedure as described here will cause downtime, even if a second restund server is up; and kill any ongoing audio/video calls. The sections further up describe a downtime and a no-downtime procedure. - -Presuming your two restund nodes are called: - -* ``restund-1`` -* ``restund-2`` - -To prepare for a reboot of ``restund-1``, log into the other restund server (``restund-2``, for example here), and make sure the docker service is running. - -List the running containers, to ensure restund is running, by executing: - -.. code:: sh - - ssh -t sudo docker container ls - -You should see the following in the results: - -.. code:: sh - - CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES - quay.io/wire/restund:v0.4.16b1.0.53 22 seconds ago Up 18 seconds restund - -Make sure you see this restund container, and it is running ("Up"). - -If it is not, you need to do troubleshooting work, if it is running, you can move forward and reboot restund-1. - -Now log into the restund server you wish to reboot (``restund-1`` in this example), and reboot it - -.. code:: sh - - ssh -t sudo reboot - -Wait at least a minute for the machine to restart, you can use this command to automatically retry SSH access until it is succesful: - -.. code:: sh - - ssh -o 'ConnectionAttempts 3600' exit - -Then log into the restund server (``restund-1``, in this example), and make sure the docker service is running: - -.. code:: sh - - ssh -t sudo docker container ls - -.. code:: sh - - CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES - quay.io/wire/restund:v0.4.16b1.0.53 22 seconds ago Up 18 seconds restund - -Here again, make sure you see a restund container, and it is running ("Up"). - -If it is, you have succesfully reboot the restund server, and can if you need to apply the same procedure to the other restund servers in your cluster. - -How to restart restund without having downtime -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For maintenance you may need to restart a restund server. - -1. Remove that restund server you want to restart from the list of advertised nodes, by taking it out of the turn server list that brig advertises: - -Go to the place where you store kubernetes configuration for your wire-server installation. This might be a directory on your admin laptop, or a directory on the kubernetes machine. - -If your override configuration (``values/wire-server/values.yaml``) looks like the following: - -.. code:: yaml - - # (...) - - brig: - # (...) - turnStatic: - v1: - # v1 entries can be ignored and are not in use anymore since end of 2018. - v2: - - turn:server1.example.com:3478 # server 1 UDP - - turn:server1.example.com:3478?transport=tcp # server 1 TCP - - turns:server1.example.com:5478?transport=tcp # server 1 TLS - - turn:server2.example.com:3478 # server 2 UDP - - turn:server2.example.com:3478?transport=tcp # server 2 TCP - - turns:server2.example.com:5478?transport=tcp # server 2 TLS - -And you want to remove server 1, then change the configuration to read - -.. code:: yaml - - turnStatic: - v2: - - turn:server2.example.com:3478 # server 2 UDP - - turn:server2.example.com:3478?transport=tcp # server 2 TCP - - turns:server2.example.com:5478?transport=tcp # server 2 TLS - -(or comment out lines by adding a ``#`` in front of the respective line) - -.. code:: yaml - - turnStatic: - v2: - #- turn:server1.example.com:3478 # server 1 UDP - #- turn:server1.example.com:3478?transport=tcp # server 1 TCP - #- turns:server1.example.com:5478?transport=tcp # server 1 TLS - - turn:server2.example.com:3478 # server 2 UDP - - turn:server2.example.com:3478?transport=tcp # server 2 TCP - - turns:server2.example.com:5478?transport=tcp # server 2 TLS - -Next, apply these changes to configuration with ``./bin/prod-setup.sh`` - -You then need to restart the ``brig`` pods if your code is older than September 2019 (otherwise brig will restart itself automatically): - -.. code:: bash - - kubectl delete pod -l app=brig - -2. Wait for traffic to drain. This can take up to 12 hours after the configuration change. Wait until current allocations (people connected to the restund server) return 0. See :ref:`allocations`. -3. It's now safe to ``systemctl stop restund``, and take any necessary actions. -4. ``systemctl start restund`` and then add the restund server back to configuration of advertised nodes (see step 1, put the server back). - -How to renew a certificate for restund -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -1. Replace the certificate file on the server (under ``/etc/restund/restund.pem`` usually), either with ansible or manually. Ensure the new certificate file is a concatenation of your whole certificate chain *and* the private key: - -.. code:: text - - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -----BEGIN CERTIFICATE----- - ... - -----END CERTIFICATE----- - -----BEGIN PRIVATE KEY----- - ... - -----END PRIVATE KEY----- - - -2. Restart restund (see sections above) - - -How to check which restund/TURN servers will be used by clients -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The list of turn servers contacted by clients *should* match what you added to your `turnStatic` configuration. But if you'd like to double-check, here's how: - -Terminal one: - -.. code:: sh - - kubectl port-forward svc/brig 9999:8080 - -Terminal two: - -.. code:: sh - - UUID=$(cat /proc/sys/kernel/random/uuid) - curl -s -H "Z-User:$UUID" -H "Z-Connection:anything" "http://localhost:9999/calls/config/v2" | json_pp - - -May return something like: - -.. code:: json - - { - "ice_servers" : [ - { - "credential" : "ASyFLXqbmg8fuK4chJG3S1Qg4L/nnhpkN0/UctdtTFbGW1AcuuAaOqUMDhm9V2w7zKHY6PPMqjhwKZ2neSE78g==", - "urls" : [ - "turn:turn1.example.com:3478" - ], - "username" : "d=1582157904.v=1.k=0.t=s.r=mbzovplogqxbasbf" - }, - { - "credential" : "ZsxEtGWbpUZ3QWxPZtbX6g53HXu6PWfhhUfGNqRBJjrsly5w9IPAsuAWLEOP7fsoSXF13mgSPROXxMYAB/fQ6g==", - "urls" : [ - "turn:turn1.example.com:3478?transport=tcp" - ], - "username" : "d=1582157904.v=1.k=0.t=s.r=jsafnwtgqhfqjvco" - }, - { - "credential" : "ZsxEtGWbpUZ3QWxPZtbX6g53HXu6PWfhhUfGNqRBJjrsly5w9IPAsuAWLEOP7fsoSXF13mgSPROXxMYAB/fQ6g==", - "urls" : [ - "turns:turn1.example.com:5349?transport=tcp" - ], - "username" : "d=1582157904.v=1.k=0.t=s.r=jsafnwtgqhfqjvco" - } - ], - "ttl" : 3600 - } - -In the above case, there is a single server configured to use UDP on port 3478, plain TCP on port 3478, and TLS over TCP on port 5349. The ordering of the list is random and will change on every request made with curl. - diff --git a/docs/src/how-to/administrate/users.md b/docs/src/how-to/administrate/users.md new file mode 100644 index 0000000000..b1ec7d1c69 --- /dev/null +++ b/docs/src/how-to/administrate/users.md @@ -0,0 +1,590 @@ +(investigative-tasks)= + +# Investigative tasks (e.g. searching for users as server admin) + +This page requires that you have root access to the machines where kubernetes runs on, or have kubernetes permissions allowing you to port-forward arbitrary pods and services. + +If you have the `backoffice` pod installed, see also the [backoffice README](https://github.com/wireapp/wire-server/tree/develop/charts/backoffice). + +If you don't have `backoffice`, see below for some options: + +## Manually searching for users in cassandra + +Terminal one: + +```sh +kubectl port-forward svc/brig 9999:8080 +``` + +Terminal two: Search for your user by email: + +```sh +EMAIL=user@example.com +curl -v -G localhost:9999/i/users --data-urlencode email=$EMAIL; echo +# or, for nicer formatting +curl -v -G localhost:9999/i/users --data-urlencode email=$EMAIL | json_pp +``` + +You can also search by `handle` (unique username) or by phone: + +```sh +HANDLE=user123 +curl -v -G localhost:9999/i/users --data-urlencode handles=$HANDLE; echo + +PHONE=+490000000000000 # phone numbers must have the +country prefix and no spaces +curl -v -G localhost:9999/i/users --data-urlencode phone=$PHONE; echo +``` + +Which should give you output like: + +```json +[ + { + "managed_by" : "wire", + "assets" : [ + { + "key" : "3-2-a749af8d-a17b-4445-b360-46c93fc41bc6", + "size" : "preview", + "type" : "image" + }, + { + "size" : "complete", + "type" : "image", + "key" : "3-2-6cac6b57-9972-4aba-acbb-f078bc538b54" + } + ], + "picture" : [], + "accent_id" : 0, + "status" : "active", + "name" : "somename", + "email" : "user@example.com", + "id" : "9122e5de-b4fb-40fa-99ad-1b5d7d07bae5", + "locale" : "en", + "handle" : "user123" + } +] +``` + +The interesting part is the `id` (in the example case `9122e5de-b4fb-40fa-99ad-1b5d7d07bae5`): + +(user-deletion)= + +## Deleting a user which is not a team user + +The following will completely delete a user, its conversations, assets, etc. The only thing remaining will be an entry in cassandra indicating that this user existed in the past (only the UUID remains, all other attributes like name etc are purged) + +You can now delete that user by double-checking that the user you wish to delete is really the correct user: + +```sh +# replace the id with the id of the user you want to delete +curl -v localhost:9999/i/users/9122e5de-b4fb-40fa-99ad-1b5d7d07bae5 -XDELETE +``` + +Afterwards, the previous command (to search for a user in cassandra) should return an empty list (`[]`). + +When done, on terminal 1, ctrl+c to cancel the port-forwarding. + +## Searching and deleting users with no team + +If you require users to be part of a team, or for some other reason you need to delete all users who are not part of a team, you need to first find all such users, and then delete them. + +To find users that are not part of a team, first you need to connect via SSH to the machine where cassandra is running, and then run the following command: + +```sh +cqlsh 9042 -e "select team, handle, id from brig.user" | grep -E "^\s+null" +``` + +This will give you a list of handles and IDs with no team associated: + +```sh +null | null | bc22119f-ce11-4402-aa70-307a58fb22ec +null | tom | 8ecee3d0-47a4-43ff-977b-40a4fc350fed +null | alice | 2a4c3468-c1e6-422f-bc4d-4aeff47941ac +null | null | 1b5ca44a-aeb4-4a68-861b-48612438c4cc +null | bob | 701b4eab-6df2-476d-a818-90dc93e8446e +``` + +You can then {ref}`delete each user with these instructions `. + +## Manual search on elasticsearch (via brig, recommended) + +This should only be necessary in the case of some (suspected) data inconsistency between cassandra and elasticsearch. + +Terminal one: + +```sh +kubectl port-forward svc/brig 9999:8080 +``` + +Terminal two: Search for your user by name or handle or a prefix of that handle or name: + +```sh +NAMEORPREFIX=test7 +UUID=$(cat /proc/sys/kernel/random/uuid) +curl -H "Z-User:$UUID" "http://localhost:9999/search/contacts?q=$NAMEORPREFIX"; echo +# or, for pretty output: +curl -H "Z-User:$UUID" "http://localhost:9999/search/contacts?q=$NAMEORPREFIX" | json_pp +``` + +If no match is found, expect a query like this: + +```json +{"took":91,"found":0,"documents":[],"returned":0} +``` + +If matches are found, the result should look like this: + +```json +{ + "found" : 2, + "documents" : [ + { + "id" : "dbdbf370-48b3-4e1e-b377-76d7d4cbb4f2", + "name" : "Test", + "handle" : "test7", + "accent_id" : 7 + }, + { + "name" : "Test", + "accent_id" : 0, + "handle" : "test7476", + "id" : "a93240b0-ba89-441e-b8ee-ff4403808f93" + } + ], + "returned" : 2, + "took" : 4 +} +``` + +## How to manually search for a user on elasticsearh directly (not recommended) + +First, ssh to an elasticsearch instance. + +```sh +ssh +``` + +Then run the following: + +```sh +PREFIX=... +curl -s "http://localhost:9200/directory/_search?q=$PREFIX" | json_pp +``` + +The `id` (UUID) returned can be used when deleting (see below). + +## How to manually delete a user from elasticsearch only + +```{warning} +This is NOT RECOMMENDED. Be sure you know what you're doing. This only deletes the user from elasticsearch, but not from cassandra. Any change of e.g. the username or displayname of that user means this user will re-appear in the elasticsearch database. Instead, either fully delete a user: {ref}`user-deletion` or make use of the internal GET/PUT `/i/searchable` endpoint on brig to make this user prefix-unsearchable. +``` + +If, despite the warning, you wish to continue? + +First, ssh to an elasticsearch instance: + +```sh +ssh +``` + +Next, check that the user exists: + +```sh +UUID=... +curl -s "http://localhost:9200/directory/user/$UUID" | json_pp +``` + +That should return a `"found": true`, like this: + +```json +{ + "_type" : "user", + "_version" : 1575998428262000, + "_id" : "b3e9e445-fb02-47f3-bac0-63f5f680d258", + "found" : true, + "_index" : "directory", + "_source" : { + "normalized" : "Mr Test", + "handle" : "test12345", + "id" : "b3e9e445-fb02-47f3-bac0-63f5f680d258", + "name" : "Mr Test", + "accent_id" : 1 + } +} +``` + +Then delete it: + +```sh +UUID=... +curl -s -XDELETE "http://localhost:9200/directory/user/$UUID" | json_pp +``` + +## Mass-invite users to a team + +If you need to invite members to a specific given team, you can use the `create_team_members.sh` Bash script, located [here](https://github.com/wireapp/wire-server/blob/develop/hack/bin/create_team_members.sh). + +This script does not create users or causes them to join a team by itself, instead, it sends invites to potential users via email, and when users accept the invitation, they create their account, set their password, and are added to the team as team members. + +Input is a [CSV file](https://en.wikipedia.org/wiki/Comma-separated_values), in comma-separated format, in the form `'Email,Suggested User Name'`. + +You also need to specify the inviting admin user, the team, the URI for the Brig ([API](https://docs.wire.com/understand/federation/api.html?highlight=brig)) service (Host), and finally the input (CSV) file containing the users to invite. + +The exact format for the parameters passed to the script is [as follows](https://github.com/wireapp/wire-server/blob/develop/hack/bin/create_team_members.sh#L17): + +- `-a `: [User ID](https://docs.wire.com/understand/federation/api.html?highlight=user%20id#qualified-identifiers-and-names) in [UUID format](https://en.wikipedia.org/wiki/Universally_unique_identifier) of the inviting admin. For example `9122e5de-b4fb-40fa-99ad-1b5d7d07bae5`. +- `-t `: ID of the inviting team, same format. +- `-h `: Base URI of brig's internal endpoint. +- `-c `: file containing info on the invitees in format 'Email,UserName'. + +For example, one such execution of the script could look like: + +```sh +sh create_team_members.sh -a 9122e5de-b4fb-40fa-99ad-1b5d7d07bae5 -t 123e4567-e89b-12d3-a456-426614174000 -h http://localhost:9999 -c users_to_invite.csv +``` + +Note: the '' implies you are running the 'kubectl port-forward' given at the top of this document +. +Once the script is run, invitations will be sent to each user in the file every second until all invitations have been sent. + +If you have a lot of invitations to send and this is too slow, you can speed things up by commenting [this line](https://github.com/wireapp/wire-server/blob/develop/hack/bin/create_team_members.sh#L91). + +## How to obtain logs from an Android client to investigate issues + +Wire clients communicate with Wire servers (backend). + +Sometimes to investigate server issues, you (or the Wire team) will need client information, in the form of client logs. + +In order to obtain client logs on the Android Wire client, follow this procedure: + +- Open the Wire app (client) on your Android device +- Click on the round user icon in the top left of the screen, leading to your user Profile. +- Click on "Settings" at the bottom of the screen +- Click on "Advanced" in the menu +- Check/activate "Collect usage data" +- Now go back to using your client normally, so usage data is generated. If you have been asked to follow a specific testing regime, or log a specific problem, this is the time to do so. +- Once enough usage data is generated, go back to the "Advanced" screen (User profile > Settings > Advanced) +- Click on "Create debug report" +- A menu will open allowing you to share the debug report, you can now save it or send it via email/any other means to the Wire team. + +## How to obtain logs from an iOS client to investigate issues + +Wire clients communicate with Wire servers (backend). + +Sometimes to investigate server issues, you (or the Wire team) will need client information, in the form of client logs. + +In order to obtain client logs on the iOS Wire client, follow this procedure: + +- Open the Wire app (client) on your iOS device +- Click on the round user icon in the top left of the screen, leading to your user Profile. +- Click on "Settings" at the bottom of the screen +- Click on "Advanced" in the menu +- Check/activate "Collect usage data" +- Now go back to using your client normally, so usage data is generated. If you have been asked to follow a specific testing regime, or log a specific problem, this is the time to do so. +- Once enough usage data is generated, go back to the "Advanced" screen (User profile > Settings > Advanced) +- Click on "Send report to wire" +- A menu will open to share the debug report via email, allowing you to send it to the Wire team. + +## How to retrieve metric values manually + +Metric values are sets of data points about services, such as status and other measures, that can be retrieved at specific endpoints, typically by a monitoring system (such as Prometheus) for monitoring, diagnosis and graphing. + +Sometimes, you will want to manually obtain this data that is normally automatically grabbed by Prometheus. + +Some of the pods allow you to grab metrics by accessing their `/i/metrics` endpoint, in particular: + +- `brig`: User management API +- `cannon`: WebSockets API +- `cargohold`: Assets storage API +- `galley`: Conversations and Teams API +- `gundeck`: Push Notifications API +- `spar`: Single-Sign-ON and SCIM + +For more details on the various services/pods, you can check out {ref}`this link `. + +Before you can grab metrics from a pod, you need to find its IP address. You do this by running the following command: + +```sh +d kubectl get pods -owide +``` + +(this presumes you are already in your normal Wire environment, which you obtain by running `source ./bin/offline-env.sh`) + +Which will give you an output that looks something like this: + +``` +demo@Ubuntu-1804-bionic-64-minimal:~/Wire-Server$ d kubectl get pods -owide +NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES +account-pages-784f9b547c-cp444 1/1 Running 0 6d23h 10.233.113.5 kubenode3 +brig-746ddc55fd-6pltz 1/1 Running 0 6d23h 10.233.110.11 kubenode2 +brig-746ddc55fd-d59dw 1/1 Running 0 6d4h 10.233.110.23 kubenode2 +brig-746ddc55fd-zp7jl 1/1 Running 0 6d23h 10.233.113.10 kubenode3 +brig-index-migrate-data-45rm7 0/1 Completed 0 6d23h 10.233.110.9 kubenode2 +cannon-0 1/1 Running 0 3h1m 10.233.119.41 kubenode1 +cannon-1 1/1 Running 0 3h1m 10.233.113.47 kubenode3 +cannon-2 1/1 Running 0 3h1m 10.233.110.51 kubenode2 +cargohold-65bff97fc6-8b9ls 1/1 Running 0 6d4h 10.233.113.20 kubenode3 +cargohold-65bff97fc6-bkx6x 1/1 Running 0 6d23h 10.233.113.4 kubenode3 +cargohold-65bff97fc6-tz8fh 1/1 Running 0 6d23h 10.233.110.5 kubenode2 +cassandra-migrations-bjsdz 0/1 Completed 0 6d23h 10.233.110.3 kubenode2 +demo-smtp-784ddf6989-vmj7t 1/1 Running 0 6d23h 10.233.113.2 kubenode3 +elasticsearch-index-create-7r8g4 0/1 Completed 0 6d23h 10.233.110.4 kubenode2 +fake-aws-sns-6c7c4b7479-wfp82 2/2 Running 0 6d4h 10.233.110.27 kubenode2 +fake-aws-sqs-59fbfbcbd4-n4c5z 2/2 Running 0 6d23h 10.233.110.2 kubenode2 +galley-7c89c44f7b-nm2rr 1/1 Running 0 6d23h 10.233.110.8 kubenode2 +galley-7c89c44f7b-tdxz4 1/1 Running 0 6d23h 10.233.113.6 kubenode3 +galley-7c89c44f7b-tr8pm 1/1 Running 0 6d4h 10.233.110.29 kubenode2 +galley-migrate-data-g66rz 0/1 Completed 0 6d23h 10.233.110.13 kubenode2 +gundeck-7fd75c7c5f-jb8xq 1/1 Running 0 6d23h 10.233.110.6 kubenode2 +gundeck-7fd75c7c5f-lbth9 1/1 Running 0 6d23h 10.233.113.8 kubenode3 +gundeck-7fd75c7c5f-wvcw6 1/1 Running 0 6d4h 10.233.113.23 kubenode3 +nginz-5cdd8b588b-dbn86 2/2 Running 16 6d23h 10.233.113.11 kubenode3 +nginz-5cdd8b588b-gk6rw 2/2 Running 14 6d23h 10.233.110.12 kubenode2 +nginz-5cdd8b588b-jvznt 2/2 Running 11 6d4h 10.233.113.21 kubenode3 +reaper-6957694667-s5vz5 1/1 Running 0 6d4h 10.233.110.26 kubenode2 +redis-ephemeral-master-0 1/1 Running 0 6d23h 10.233.113.3 kubenode3 +spar-56d77f85f6-bw55q 1/1 Running 0 6d23h 10.233.113.9 kubenode3 +spar-56d77f85f6-mczzd 1/1 Running 0 6d4h 10.233.110.28 kubenode2 +spar-56d77f85f6-vvvfq 1/1 Running 0 6d23h 10.233.110.7 kubenode2 +spar-migrate-data-ts4sx 0/1 Completed 0 6d23h 10.233.110.14 kubenode2 +team-settings-fbbb899c-qxx7m 1/1 Running 0 6d4h 10.233.110.24 kubenode2 +webapp-d97869795-grnft 1/1 Running 0 6d4h 10.233.110.25 kubenode2 +``` + +Here presuming we need to get metrics from `gundeck`, we can see the IP of one of the gundeck pods is `10.233.110.6`. + +We can therefore connect to node `kubenode2` on which this pod runs with `ssh kubenode2.your-domain.com`, and run the following: + +```sh +curl 10.233.110.6:8080/i/metrics +``` + +Alternatively, if you don't want to, or can't for some reason, connect to kubenode2, you can use port redirect instead: + +```sh +# Allow Gundeck to be reached via the port 7777 +kubectl --kubeconfig kubeconfig.dec -n wire port-forward service/gundeck 7777:8080 +# Reach Gundeck directly at port 7777 using curl, output resulting data to stdout/terminal +curl -v http://127.0.0.1:7777/i/metrics +``` + +Output will look something like this (truncated): + +```sh +# HELP gc_seconds_wall Wall clock time spent on last GC +# TYPE gc_seconds_wall gauge +gc_seconds_wall 5481304.0 +# HELP gc_seconds_cpu CPU time spent on last GC +# TYPE gc_seconds_cpu gauge +gc_seconds_cpu 5479828.0 +# HELP gc_bytes_used_current Number of bytes in active use as of the last GC +# TYPE gc_bytes_used_current gauge +gc_bytes_used_current 1535232.0 +# HELP gc_bytes_used_max Maximum amount of memory living on the heap after the last major GC +# TYPE gc_bytes_used_max gauge +gc_bytes_used_max 2685312.0 +# HELP gc_bytes_allocated_total Bytes allocated since the start of the server +# TYPE gc_bytes_allocated_total gauge +gc_bytes_allocated_total 4.949156056e9 +``` + +This example is for Gundeck, but you can also get metrics for other services. All k8s services are listed at {ref}`this link `. + +This is an example adapted for Cannon: + +```sh +kubectl --kubeconfig kubeconfig.dec -n wire port-forward service/cannon 7777:8080 +curl -v http://127.0.0.1:7777/i/metrics +``` + +In the output of this command, `net_websocket_clients` is roughly the number of connected clients. + +(reset-session-cookies)= + +## Reset session cookies + +Remove session cookies on your system to force users to login again within the next 15 minutes (or whenever they come back online): + +```{warning} +This will cause interruptions to ongoing calls and should be timed properly. +``` + +### Reset cookies of all users + +```sh +ssh +# from the ssh session +cqlsh +# from the cqlsh shell +truncate brig.user_cookies; +``` + +### Reset cookies for a defined list of users + +```sh +ssh +# within the ssh session +cqlsh +# within the cqlsh shell: delete all users by userId +delete from brig.user_cookies where user in (c0d64244-8ab4-11ec-8fda-37788be3a4e2, ...); +``` + +(Keep reading if you want to find out which users on your system are using SSO.) + +(identify-sso-users)= + +## Identify all users using SSO + +Collect all teams configured with an IdP: + +```sh +ssh +# within the ssh session start cqlsh +cqlsh +# within the cqlsh shell export all teams with idp +copy spar.idp (team) TO 'teams_with_idp.csv' with header=false; +``` + +Close the session and proceed locally: + +```sh +# download csv file +scp :teams_with_idp.csv . +# convert to a single line, comma separated list +tr '\n' ',' < teams_with_idp.csv; echo +``` + +And use this list to get all team members in these teams: + +```sh +ssh +# within the ssh session start cqlsh +cqlsh +# within the cqlsh shell select all members of previous identified teams +# should look like this: f2207d98-8ab3-11ec-b689-07fc1fd409c9, ... +select user from galley.team_member where team in (); +# alternatively, export the list of all users (for filterling locally in eg. excel) +copy galley.team_member (user, team, sso_id) TO 'users_with_idp.csv' with header=true; +``` + +Close the session and proceed locally to generate the list of all users from teams with IdP: + +```sh +# download csv file +scp :users_with_idp.csv . +# convert to a single line, comma separated list +tr '\n' ',' < users_with_idp.csv; echo +``` + +```{note} +Don't forget to dellete the created csv files after you have downloaded/processed them. +``` + +## Create a team using the SCIM API + +If you need to create a team manually, maybe because team creation was blocked in the "teams" interface, follow this procedure: + +First download or locate this bash script: `wire-server/hack/bin/create_test_team_scim.sh ` + +Then, run it the following way: + +```sh +./create_test_team_scim.sh -h -s +``` + +Where: + +- In `-h `, replace `` with the base URL for your brig host (for example: `https://brig-host.your-domain.com`, defaults to `http://localhost:8082`) +- In `-s `, replace `` with the base URL for your spar host (for example: `https://spar-host.your-domain.com`, defaults to `http://localhost:8088`) + +You might also need to edit the admin email and admin passwords at lines `48` and `49` of the script. + +To learn more about the different pods and how to identify them, see `this page`. + +You can list your pods with `kubectl get pods --namespace wire`. + +Alternatively, you can run the series of commands manually with `curl`, like this: + +```sh +curl -i -s --show-error \ + -XPOST "$BRIG_HOST/i/users" \ + -H'Content-type: application/json' \ + -d'{"email":"$ADMIN_EMAIL","password":"$ADMIN_PASSWORD","name":"$NAME_OF_TEAM","team":{"name":"$NAME_OF_TEAM","icon":"default"}}' +``` + +Where: + +- `$BRIG_HOST` is the base URL for your brig host +- `$ADMIN_EMAIL` is the email for the admin account for the new team +- `$ADMIN_PASSWORD` is the password for the admin account for the new team +- `$NAME_OF_TEAM` is the name of the team newly created + +Out of the result of this command, you will be able to extract an `Admin UUID`, and a `Team UUID`, which you will need later. + +Then run: + +```sh +curl -X POST \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json' \ + -d '{"email":"$ADMIN_EMAIL","password":"$ADMIN_PASSWORD"}' \ + $BRIG_HOST/login'?persist=false' | jq -r .access_token +``` + +Where the values to replace are the same as the command above. + +This command should output an access token, take note of it. + +Then run: + +```sh +curl -X POST \ + --header "Authorization: Bearer $ACCESS_TOKEN" \ + --header 'Content-Type: application/json;charset=utf-8' \ + --header 'Z-User: '"$ADMIN_UUID" \ + -d '{ "description": "test '"`date`"'", "password": "'"$ADMIN_PASSWORD"'" }' \ + $SPAR_HOST/scim/auth-tokens +``` + +Where the values to replace are the same as the first command, plus `$ACCESS_TOKEN` is access token you just took note of in the previous command. + +Out of the JSON output of this command, you should be able to extract: + +- A SCIM token (`token` value in the JSON). +- A SCIM token ID (`id` value in the `info` value in the JSON) + +Equiped with those tokens, we move on to the next script, `wire-server/hack/bin/create_team.sh ` + +This script can be run the following way: + +```sh +./create_team.sh -h -o -e -p -v -t -c +``` + +Where: + +- -h \: Base URI of brig. default: `http://localhost:8080` +- -o \: user display name of the owner of the team to be created. default: "owner name n/a" +- -e \: email address of the owner of the team to be created. default: "owner email n/a" +- -p \: owner password. default: "owner pass n/a" +- -v \: validation code received by email after running the previous script/commands. default: "email code n/a" +- -t \: default: "team name n/a" +- -c \: default: "USD" + +Alternatively, you can manually run the command: + +```sh +curl -i -s --show-error \ + -XPOST "$BRIG_HOST/register" \ + -H'Content-type: application/json' \ + -d'{"name":"$OWNER_NAME","email":"$OWNER_EMAIL","password":"$OWNER_PASSWORD","email_code":"$EMAIL_CODE","team":{"currency":"$TEAM_CURRENCY","icon":"default","name":"$TEAM_NAME"}}' +``` + +Where: + +- `$BRIG_HOST` is the base URL for your brig service +- `$OWNER_NAME` is the name of the of the team to be created +- `$OWNER_PASSWORD` is the password of the owner of the team to be created +- `$EMAIL_CODE` is the validation code received by email after running the previous script/command +- `$TEAM_CURRENCY` is the currency of the team +- `$TEAM_NAME` is the name of the team diff --git a/docs/src/how-to/administrate/users.rst b/docs/src/how-to/administrate/users.rst deleted file mode 100644 index e7d1e856dc..0000000000 --- a/docs/src/how-to/administrate/users.rst +++ /dev/null @@ -1,609 +0,0 @@ -.. _investigative_tasks: - -Investigative tasks (e.g. searching for users as server admin) ---------------------------------------------------------------- - -This page requires that you have root access to the machines where kubernetes runs on, or have kubernetes permissions allowing you to port-forward arbitrary pods and services. - -If you have the `backoffice` pod installed, see also the `backoffice README `__. - -If you don't have `backoffice`, see below for some options: - -Manually searching for users in cassandra -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Terminal one: - -.. code:: sh - - kubectl port-forward svc/brig 9999:8080 - -Terminal two: Search for your user by email: - -.. code:: sh - - EMAIL=user@example.com - curl -v -G localhost:9999/i/users --data-urlencode email=$EMAIL; echo - # or, for nicer formatting - curl -v -G localhost:9999/i/users --data-urlencode email=$EMAIL | json_pp - -You can also search by ``handle`` (unique username) or by phone: - -.. code:: sh - - HANDLE=user123 - curl -v -G localhost:9999/i/users --data-urlencode handles=$HANDLE; echo - - PHONE=+490000000000000 # phone numbers must have the +country prefix and no spaces - curl -v -G localhost:9999/i/users --data-urlencode phone=$PHONE; echo - - -Which should give you output like: - -.. code:: json - - [ - { - "managed_by" : "wire", - "assets" : [ - { - "key" : "3-2-a749af8d-a17b-4445-b360-46c93fc41bc6", - "size" : "preview", - "type" : "image" - }, - { - "size" : "complete", - "type" : "image", - "key" : "3-2-6cac6b57-9972-4aba-acbb-f078bc538b54" - } - ], - "picture" : [], - "accent_id" : 0, - "status" : "active", - "name" : "somename", - "email" : "user@example.com", - "id" : "9122e5de-b4fb-40fa-99ad-1b5d7d07bae5", - "locale" : "en", - "handle" : "user123" - } - ] - -The interesting part is the ``id`` (in the example case ``9122e5de-b4fb-40fa-99ad-1b5d7d07bae5``): - -.. _user-deletion: - -Deleting a user which is not a team user -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The following will completely delete a user, its conversations, assets, etc. The only thing remaining will be an entry in cassandra indicating that this user existed in the past (only the UUID remains, all other attributes like name etc are purged) - -You can now delete that user by double-checking that the user you wish to delete is really the correct user: - -.. code:: sh - - # replace the id with the id of the user you want to delete - curl -v localhost:9999/i/users/9122e5de-b4fb-40fa-99ad-1b5d7d07bae5 -XDELETE - -Afterwards, the previous command (to search for a user in cassandra) should return an empty list (``[]``). - -When done, on terminal 1, ctrl+c to cancel the port-forwarding. - -Searching and deleting users with no team -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you require users to be part of a team, or for some other reason you need to delete all users who are not part of a team, you need to first find all such users, and then delete them. - -To find users that are not part of a team, first you need to connect via SSH to the machine where cassandra is running, and then run the following command: - -.. code:: sh - - cqlsh 9042 -e "select team, handle, id from brig.user" | grep -E "^\s+null" - -This will give you a list of handles and IDs with no team associated: - -.. code:: sh - - null | null | bc22119f-ce11-4402-aa70-307a58fb22ec - null | tom | 8ecee3d0-47a4-43ff-977b-40a4fc350fed - null | alice | 2a4c3468-c1e6-422f-bc4d-4aeff47941ac - null | null | 1b5ca44a-aeb4-4a68-861b-48612438c4cc - null | bob | 701b4eab-6df2-476d-a818-90dc93e8446e - -You can then `delete each user with these instructions <./users.html#deleting-a-user-which-is-not-a-team-user>`__. - -Manual search on elasticsearch (via brig, recommended) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This should only be necessary in the case of some (suspected) data inconsistency between cassandra and elasticsearch. - -Terminal one: - -.. code:: sh - - kubectl port-forward svc/brig 9999:8080 - -Terminal two: Search for your user by name or handle or a prefix of that handle or name: - -.. code:: sh - - NAMEORPREFIX=test7 - UUID=$(cat /proc/sys/kernel/random/uuid) - curl -H "Z-User:$UUID" "http://localhost:9999/search/contacts?q=$NAMEORPREFIX"; echo - # or, for pretty output: - curl -H "Z-User:$UUID" "http://localhost:9999/search/contacts?q=$NAMEORPREFIX" | json_pp - -If no match is found, expect a query like this: - -.. code:: json - - {"took":91,"found":0,"documents":[],"returned":0} - -If matches are found, the result should look like this: - -.. code:: json - - { - "found" : 2, - "documents" : [ - { - "id" : "dbdbf370-48b3-4e1e-b377-76d7d4cbb4f2", - "name" : "Test", - "handle" : "test7", - "accent_id" : 7 - }, - { - "name" : "Test", - "accent_id" : 0, - "handle" : "test7476", - "id" : "a93240b0-ba89-441e-b8ee-ff4403808f93" - } - ], - "returned" : 2, - "took" : 4 - } - -How to manually search for a user on elasticsearh directly (not recommended) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -First, ssh to an elasticsearch instance. - -.. code:: sh - - ssh - -Then run the following: - -.. code:: sh - - PREFIX=... - curl -s "http://localhost:9200/directory/_search?q=$PREFIX" | json_pp - -The `id` (UUID) returned can be used when deleting (see below). - -How to manually delete a user from elasticsearch only -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. warning:: - - This is NOT RECOMMENDED. Be sure you know what you're doing. This only deletes the user from elasticsearch, but not from cassandra. Any change of e.g. the username or displayname of that user means this user will re-appear in the elasticsearch database. Instead, either fully delete a user: :ref:`user-deletion` or make use of the internal GET/PUT ``/i/searchable`` endpoint on brig to make this user prefix-unsearchable. - -If, despite the warning, you wish to continue? - -First, ssh to an elasticsearch instance: - -.. code:: sh - - ssh - -Next, check that the user exists: - -.. code:: sh - - UUID=... - curl -s "http://localhost:9200/directory/user/$UUID" | json_pp - -That should return a ``"found": true``, like this: - -.. code:: json - - { - "_type" : "user", - "_version" : 1575998428262000, - "_id" : "b3e9e445-fb02-47f3-bac0-63f5f680d258", - "found" : true, - "_index" : "directory", - "_source" : { - "normalized" : "Mr Test", - "handle" : "test12345", - "id" : "b3e9e445-fb02-47f3-bac0-63f5f680d258", - "name" : "Mr Test", - "accent_id" : 1 - } - } - - -Then delete it: - -.. code:: sh - - UUID=... - curl -s -XDELETE "http://localhost:9200/directory/user/$UUID" | json_pp - -Mass-invite users to a team -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you need to invite members to a specific given team, you can use the ``create_team_members.sh`` Bash script, located `here `__. - -This script does not create users or causes them to join a team by itself, instead, it sends invites to potential users via email, and when users accept the invitation, they create their account, set their password, and are added to the team as team members. - -Input is a `CSV file `__, in comma-separated format, in the form ``'Email,Suggested User Name'``. - -You also need to specify the inviting admin user, the team, the URI for the Brig (`API `__) service (Host), and finally the input (CSV) file containing the users to invite. - -The exact format for the parameters passed to the script is `as follows `__: - -* ``-a ``: `User ID `__ in `UUID format `__ of the inviting admin. For example ``9122e5de-b4fb-40fa-99ad-1b5d7d07bae5``. -* ``-t ``: ID of the inviting team, same format. -* ``-h ``: Base URI of brig's internal endpoint. -* ``-c ``: file containing info on the invitees in format 'Email,UserName'. - -For example, one such execution of the script could look like: - -.. code:: sh - - sh create_team_members.sh -a 9122e5de-b4fb-40fa-99ad-1b5d7d07bae5 -t 123e4567-e89b-12d3-a456-426614174000 -h http://localhost:9999 -c users_to_invite.csv - -Note: the 'http://localhost:9999' implies you are running the 'kubectl port-forward' given at the top of this document -. -Once the script is run, invitations will be sent to each user in the file every second until all invitations have been sent. - -If you have a lot of invitations to send and this is too slow, you can speed things up by commenting `this line `__. - - -How to obtain logs from an Android client to investigate issues -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Wire clients communicate with Wire servers (backend). - -Sometimes to investigate server issues, you (or the Wire team) will need client information, in the form of client logs. - -In order to obtain client logs on the Android Wire client, follow this procedure: - -* Open the Wire app (client) on your Android device -* Click on the round user icon in the top left of the screen, leading to your user Profile. -* Click on "Settings" at the bottom of the screen -* Click on "Advanced" in the menu -* Check/activate "Collect usage data" -* Now go back to using your client normally, so usage data is generated. If you have been asked to follow a specific testing regime, or log a specific problem, this is the time to do so. -* Once enough usage data is generated, go back to the "Advanced" screen (User profile > Settings > Advanced) -* Click on "Create debug report" -* A menu will open allowing you to share the debug report, you can now save it or send it via email/any other means to the Wire team. - - -How to obtain logs from an iOS client to investigate issues -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Wire clients communicate with Wire servers (backend). - -Sometimes to investigate server issues, you (or the Wire team) will need client information, in the form of client logs. - -In order to obtain client logs on the iOS Wire client, follow this procedure: - -* Open the Wire app (client) on your iOS device -* Click on the round user icon in the top left of the screen, leading to your user Profile. -* Click on "Settings" at the bottom of the screen -* Click on "Advanced" in the menu -* Check/activate "Collect usage data" -* Now go back to using your client normally, so usage data is generated. If you have been asked to follow a specific testing regime, or log a specific problem, this is the time to do so. -* Once enough usage data is generated, go back to the "Advanced" screen (User profile > Settings > Advanced) -* Click on "Send report to wire" -* A menu will open to share the debug report via email, allowing you to send it to the Wire team. - -How to retrieve metric values manually -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Metric values are sets of data points about services, such as status and other measures, that can be retrieved at specific endpoints, typically by a monitoring system (such as Prometheus) for monitoring, diagnosis and graphing. - -Sometimes, you will want to manually obtain this data that is normally automatically grabbed by Prometheus. - -Some of the pods allow you to grab metrics by accessing their ``/i/metrics`` endpoint, in particular: - -* ``brig``: User management API -* ``cannon``: WebSockets API -* ``cargohold``: Assets storage API -* ``galley``: Conversations and Teams API -* ``gundeck``: Push Notifications API -* ``spar``: Single-Sign-ON and SCIM - -For more details on the various services/pods, you can check out `this link <../../understand/overview.html?highlight=gundeck#focus-on-pods>`. - -Before you can grab metrics from a pod, you need to find its IP address. You do this by running the following command: - -.. code:: sh - - d kubectl get pods -owide - -(this presumes you are already in your normal Wire environment, which you obtain by running ``source ./bin/offline-env.sh``) - -Which will give you an output that looks something like this: - -.. code:: - - demo@Ubuntu-1804-bionic-64-minimal:~/Wire-Server$ d kubectl get pods -owide - NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES - account-pages-784f9b547c-cp444 1/1 Running 0 6d23h 10.233.113.5 kubenode3 - brig-746ddc55fd-6pltz 1/1 Running 0 6d23h 10.233.110.11 kubenode2 - brig-746ddc55fd-d59dw 1/1 Running 0 6d4h 10.233.110.23 kubenode2 - brig-746ddc55fd-zp7jl 1/1 Running 0 6d23h 10.233.113.10 kubenode3 - brig-index-migrate-data-45rm7 0/1 Completed 0 6d23h 10.233.110.9 kubenode2 - cannon-0 1/1 Running 0 3h1m 10.233.119.41 kubenode1 - cannon-1 1/1 Running 0 3h1m 10.233.113.47 kubenode3 - cannon-2 1/1 Running 0 3h1m 10.233.110.51 kubenode2 - cargohold-65bff97fc6-8b9ls 1/1 Running 0 6d4h 10.233.113.20 kubenode3 - cargohold-65bff97fc6-bkx6x 1/1 Running 0 6d23h 10.233.113.4 kubenode3 - cargohold-65bff97fc6-tz8fh 1/1 Running 0 6d23h 10.233.110.5 kubenode2 - cassandra-migrations-bjsdz 0/1 Completed 0 6d23h 10.233.110.3 kubenode2 - demo-smtp-784ddf6989-vmj7t 1/1 Running 0 6d23h 10.233.113.2 kubenode3 - elasticsearch-index-create-7r8g4 0/1 Completed 0 6d23h 10.233.110.4 kubenode2 - fake-aws-sns-6c7c4b7479-wfp82 2/2 Running 0 6d4h 10.233.110.27 kubenode2 - fake-aws-sqs-59fbfbcbd4-n4c5z 2/2 Running 0 6d23h 10.233.110.2 kubenode2 - galley-7c89c44f7b-nm2rr 1/1 Running 0 6d23h 10.233.110.8 kubenode2 - galley-7c89c44f7b-tdxz4 1/1 Running 0 6d23h 10.233.113.6 kubenode3 - galley-7c89c44f7b-tr8pm 1/1 Running 0 6d4h 10.233.110.29 kubenode2 - galley-migrate-data-g66rz 0/1 Completed 0 6d23h 10.233.110.13 kubenode2 - gundeck-7fd75c7c5f-jb8xq 1/1 Running 0 6d23h 10.233.110.6 kubenode2 - gundeck-7fd75c7c5f-lbth9 1/1 Running 0 6d23h 10.233.113.8 kubenode3 - gundeck-7fd75c7c5f-wvcw6 1/1 Running 0 6d4h 10.233.113.23 kubenode3 - nginz-5cdd8b588b-dbn86 2/2 Running 16 6d23h 10.233.113.11 kubenode3 - nginz-5cdd8b588b-gk6rw 2/2 Running 14 6d23h 10.233.110.12 kubenode2 - nginz-5cdd8b588b-jvznt 2/2 Running 11 6d4h 10.233.113.21 kubenode3 - reaper-6957694667-s5vz5 1/1 Running 0 6d4h 10.233.110.26 kubenode2 - redis-ephemeral-master-0 1/1 Running 0 6d23h 10.233.113.3 kubenode3 - spar-56d77f85f6-bw55q 1/1 Running 0 6d23h 10.233.113.9 kubenode3 - spar-56d77f85f6-mczzd 1/1 Running 0 6d4h 10.233.110.28 kubenode2 - spar-56d77f85f6-vvvfq 1/1 Running 0 6d23h 10.233.110.7 kubenode2 - spar-migrate-data-ts4sx 0/1 Completed 0 6d23h 10.233.110.14 kubenode2 - team-settings-fbbb899c-qxx7m 1/1 Running 0 6d4h 10.233.110.24 kubenode2 - webapp-d97869795-grnft 1/1 Running 0 6d4h 10.233.110.25 kubenode2 - -Here presuming we need to get metrics from ``gundeck``, we can see the IP of one of the gundeck pods is ``10.233.110.6``. - -We can therefore connect to node ``kubenode2`` on which this pod runs with ``ssh kubenode2.your-domain.com``, and run the following: - -.. code:: sh - - curl 10.233.110.6:8080/i/metrics - -Alternatively, if you don't want to, or can't for some reason, connect to kubenode2, you can use port redirect instead: - -.. code:: sh - - # Allow Gundeck to be reached via the port 7777 - kubectl --kubeconfig kubeconfig.dec -n wire port-forward service/gundeck 7777:8080 - # Reach Gundeck directly at port 7777 using curl, output resulting data to stdout/terminal - curl -v http://127.0.0.1:7777/i/metrics - -Output will look something like this (truncated): - -.. code:: sh - - # HELP gc_seconds_wall Wall clock time spent on last GC - # TYPE gc_seconds_wall gauge - gc_seconds_wall 5481304.0 - # HELP gc_seconds_cpu CPU time spent on last GC - # TYPE gc_seconds_cpu gauge - gc_seconds_cpu 5479828.0 - # HELP gc_bytes_used_current Number of bytes in active use as of the last GC - # TYPE gc_bytes_used_current gauge - gc_bytes_used_current 1535232.0 - # HELP gc_bytes_used_max Maximum amount of memory living on the heap after the last major GC - # TYPE gc_bytes_used_max gauge - gc_bytes_used_max 2685312.0 - # HELP gc_bytes_allocated_total Bytes allocated since the start of the server - # TYPE gc_bytes_allocated_total gauge - gc_bytes_allocated_total 4.949156056e9 - -This example is for Gundeck, but you can also get metrics for other services. All k8s services are listed at `this link <../../understand/overview.html?highlight=gundeck#focus-on-pods>`__. - -This is an example adapted for Cannon: - -.. code:: sh - - kubectl --kubeconfig kubeconfig.dec -n wire port-forward service/cannon 7777:8080 - curl -v http://127.0.0.1:7777/i/metrics - -In the output of this command, ``net_websocket_clients`` is roughly the number of connected clients. - -.. _reset session cookies: - -Reset session cookies -~~~~~~~~~~~~~~~~~~~~~ - -Remove session cookies on your system to force users to login again within the next 15 minutes (or whenever they come back online): - -.. warning:: - This will cause interruptions to ongoing calls and should be timed properly. - -Reset cookies of all users -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: sh - - ssh - # from the ssh session - cqlsh - # from the cqlsh shell - truncate brig.user_cookies; - -Reset cookies for a defined list of users -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code:: sh - - ssh - # within the ssh session - cqlsh - # within the cqlsh shell: delete all users by userId - delete from brig.user_cookies where user in (c0d64244-8ab4-11ec-8fda-37788be3a4e2, ...); - -(Keep reading if you want to find out which users on your system are using SSO.) - -.. _identify sso users: - -Identify all users using SSO -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Collect all teams configured with an IdP: - -.. code:: sh - - ssh - # within the ssh session start cqlsh - cqlsh - # within the cqlsh shell export all teams with idp - copy spar.idp (team) TO 'teams_with_idp.csv' with header=false; - -Close the session and proceed locally: - -.. code:: sh - - # download csv file - scp :teams_with_idp.csv . - # convert to a single line, comma separated list - tr '\n' ',' < teams_with_idp.csv; echo - -And use this list to get all team members in these teams: - -.. code:: sh - - ssh - # within the ssh session start cqlsh - cqlsh - # within the cqlsh shell select all members of previous identified teams - # should look like this: f2207d98-8ab3-11ec-b689-07fc1fd409c9, ... - select user from galley.team_member where team in (); - # alternatively, export the list of all users (for filterling locally in eg. excel) - copy galley.team_member (user, team, sso_id) TO 'users_with_idp.csv' with header=true; - -Close the session and proceed locally to generate the list of all users from teams with IdP: - -.. code:: sh - - # download csv file - scp :users_with_idp.csv . - # convert to a single line, comma separated list - tr '\n' ',' < users_with_idp.csv; echo - - -.. note:: - Don't forget to dellete the created csv files after you have downloaded/processed them. - -Create a team using the SCIM API -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you need to create a team manually, maybe because team creation was blocked in the "teams" interface, follow this procedure: - -First download or locate this bash script: `wire-server/hack/bin/create_test_team_scim.sh ` - -Then, run it the following way: - -.. code:: sh - - ./create_test_team_scim.sh -h -s - -Where: - -* In `-h `, replace `` with the base URL for your brig host (for example: `https://brig-host.your-domain.com`, defaults to `http://localhost:8082`) -* In `-s `, replace `` with the base URL for your spar host (for example: `https://spar-host.your-domain.com`, defaults to `http://localhost:8088`) - -You might also need to edit the admin email and admin passwords at lines `48` and `49` of the script. - -To learn more about the different pods and how to identify them, see `this page`. - -You can list your pods with `kubectl get pods --namespace wire`. - -Alternatively, you can run the series of commands manually with `curl`, like this: - -.. code:: sh - - curl -i -s --show-error \ - -XPOST "$BRIG_HOST/i/users" \ - -H'Content-type: application/json' \ - -d'{"email":"$ADMIN_EMAIL","password":"$ADMIN_PASSWORD","name":"$NAME_OF_TEAM","team":{"name":"$NAME_OF_TEAM","icon":"default"}}' - -Where: - -* `$BRIG_HOST` is the base URL for your brig host -* `$ADMIN_EMAIL` is the email for the admin account for the new team -* `$ADMIN_PASSWORD` is the password for the admin account for the new team -* `$NAME_OF_TEAM` is the name of the team newly created - -Out of the result of this command, you will be able to extract an `Admin UUID`, and a `Team UUID`, which you will need later. - -Then run: - -.. code:: sh - - curl -X POST \ - --header 'Content-Type: application/json' \ - --header 'Accept: application/json' \ - -d '{"email":"$ADMIN_EMAIL","password":"$ADMIN_PASSWORD"}' \ - $BRIG_HOST/login'?persist=false' | jq -r .access_token - -Where the values to replace are the same as the command above. - -This command should output an access token, take note of it. - -Then run: - -.. code:: sh - - curl -X POST \ - --header "Authorization: Bearer $ACCESS_TOKEN" \ - --header 'Content-Type: application/json;charset=utf-8' \ - --header 'Z-User: '"$ADMIN_UUID" \ - -d '{ "description": "test '"`date`"'", "password": "'"$ADMIN_PASSWORD"'" }' \ - $SPAR_HOST/scim/auth-tokens - -Where the values to replace are the same as the first command, plus `$ACCESS_TOKEN` is access token you just took note of in the previous command. - -Out of the JSON output of this command, you should be able to extract: - -* A SCIM token (`token` value in the JSON). -* A SCIM token ID (`id` value in the `info` value in the JSON) - -Equiped with those tokens, we move on to the next script, `wire-server/hack/bin/create_team.sh ` - -This script can be run the following way: - -.. code:: sh - - ./create_team.sh -h -o -e -p -v -t -c - -Where: - -* -h : Base URI of brig. default: `http://localhost:8080` -* -o : user display name of the owner of the team to be created. default: "owner name n/a" -* -e : email address of the owner of the team to be created. default: "owner email n/a" -* -p : owner password. default: "owner pass n/a" -* -v : validation code received by email after running the previous script/commands. default: "email code n/a" -* -t : default: "team name n/a" -* -c : default: "USD" - -Alternatively, you can manually run the command: - -.. code:: sh - - curl -i -s --show-error \ - -XPOST "$BRIG_HOST/register" \ - -H'Content-type: application/json' \ - -d'{"name":"$OWNER_NAME","email":"$OWNER_EMAIL","password":"$OWNER_PASSWORD","email_code":"$EMAIL_CODE","team":{"currency":"$TEAM_CURRENCY","icon":"default","name":"$TEAM_NAME"}}' - -Where: - -* `$BRIG_HOST` is the base URL for your brig service -* `$OWNER_NAME` is the name of the of the team to be created -* `$OWNER_PASSWORD` is the password of the owner of the team to be created -* `$EMAIL_CODE` is the validation code received by email after running the previous script/command -* `$TEAM_CURRENCY` is the currency of the team -* `$TEAM_NAME` is the name of the team diff --git a/docs/src/how-to/associate/custom-backend-for-desktop-client.md b/docs/src/how-to/associate/custom-backend-for-desktop-client.md new file mode 100644 index 0000000000..ad66ca975e --- /dev/null +++ b/docs/src/how-to/associate/custom-backend-for-desktop-client.md @@ -0,0 +1,79 @@ +# How to connect the desktop application to a custom backend + +## Introduction + +This page explains how to connect the Wire desktop client to a custom Backend, which can be done either via a start-up parameter or via an initialization file. + +## Prerequisites + +Install Wire either from the App Store, or download it from our website at () + +Have a running Wire backend in your infrastructure/cloud. + +Note down the full URL of the webapp served by that backend (e.g. ) + +## Using start-up parameters + +### Windows + +- Create a shortcut to the Wire application +- Edit the shortcut ( Right click > Properties ) +- Add the following command line parameters to the shortcut: `--env {URL}`, where `{URL}` is the URL of your webapp as noted down above + +### MacOS + +To create the application + +- Open Automator +- Click New application +- Add the "Run shell script" phase +- Type in the script panel the following command: `open -b com.wearezeta.zclient.mac --args --env {URL}`, where `{URL}` is the URL of your webapp as noted down above +- Save the application from Automator (e.g. on your desktop or in Application) +- To run the application: Just open the application you created in the first step + +### Linux + +- Open a Terminal +- Start the application with the command line arguments: `--env {URL}`, where `{URL}` is the URL of your webapp as noted down above + +## Using an initialization file + +By providing an initialization file the instance connection parameters and/or proxy settings for the Wire desktop application can be pre-configured. This requires Wire version >= 3.27. + +Create a file named `init.json` and set `customWebAppURL` and optionally `proxyServerURL` e.g. as follows: + +```json +{ + "customWebAppURL": "https://app.custom-wire.com", + "env": "CUSTOM", + "proxyServerURL": "http://127.0.0.1:3128", +} +``` + +The `env` setting must be set to `CUSTOM` for this to work. + +```{note} +Consult your site admin to learn what goes into these settings. The value of `customWebAppURL` can be found [here](https://github.com/wireapp/wire-server/blob/e6aa50913cdcfde1200114787baabf7896394a2f/charts/webapp/templates/deployment.yaml#L40-L41) or [resp. here](https://github.com/wireapp/wire-server/blob/e6aa50913cdcfde1200114787baabf7896394a2f/charts/webapp/values.yaml#L26). The value of `proxyServerURL` is your browser proxy. It depends on the configuration of the network your client is running in. +``` + +### Windows + +Move the `init.json` file to `%APPDATA%\Wire\config\init.json` if it does not already exist. Otherwise update it accordingly. + +### MacOS + +Move the `init.json` file to + +``` +~/Library/Containers/com.wearezeta.zclient.mac/Data/Library/Application\ Support/Wire/config/init.json +``` + +if it does not already exist. Otherwise, update it accordingly. + +### Linux + +On Linux the `init.json` file should be located in the following directory: + +``` +$HOME/.config/Wire/config/init.json +``` diff --git a/docs/src/how-to/associate/custom-backend-for-desktop-client.rst b/docs/src/how-to/associate/custom-backend-for-desktop-client.rst deleted file mode 100644 index 6f4f768345..0000000000 --- a/docs/src/how-to/associate/custom-backend-for-desktop-client.rst +++ /dev/null @@ -1,90 +0,0 @@ -How to connect the desktop application to a custom backend -========================================================== - -Introduction ------------- - -This page explains how to connect the Wire desktop client to a custom Backend, which can be done either via a start-up parameter or via an initialization file. - -Prerequisites --------------- - -Install Wire either from the App Store, or download it from our website at (https://wire.com/en/download/) - -Have a running Wire backend in your infrastructure/cloud. - -Note down the full URL of the webapp served by that backend (e.g. https://app.custom-wire.com ) - -Using start-up parameters -------------------------- - -Windows -~~~~~~~ - -- Create a shortcut to the Wire application -- Edit the shortcut ( Right click > Properties ) -- Add the following command line parameters to the shortcut: `--env {URL}`, where `{URL}` is the URL of your webapp as noted down above - -MacOS -~~~~~ - -To create the application - -- Open Automator -- Click New application -- Add the "Run shell script" phase -- Type in the script panel the following command: `open -b com.wearezeta.zclient.mac --args --env {URL}`, where `{URL}` is the URL of your webapp as noted down above -- Save the application from Automator (e.g. on your desktop or in Application) -- To run the application: Just open the application you created in the first step - -Linux -~~~~~ - -- Open a Terminal -- Start the application with the command line arguments: `--env {URL}`, where `{URL}` is the URL of your webapp as noted down above - -Using an initialization file ----------------------------- - -By providing an initialization file the instance connection parameters and/or proxy settings for the Wire desktop application can be pre-configured. This requires Wire version >= 3.27. - -Create a file named ``init.json`` and set ``customWebAppURL`` and optionally ``proxyServerURL`` e.g. as follows: - -.. code-block:: json - - { - "customWebAppURL": "https://app.custom-wire.com", - "env": "CUSTOM", - "proxyServerURL": "http://127.0.0.1:3128", - } - -The ``env`` setting must be set to ``CUSTOM`` for this to work. - -.. note:: - - Consult your site admin to learn what goes into these settings. The value of ``customWebAppURL`` can be found `here `_ or `resp. here `_. The value of ``proxyServerURL`` is your browser proxy. It depends on the configuration of the network your client is running in. - -Windows -~~~~~~~ - -Move the ``init.json`` file to ``%APPDATA%\Wire\config\init.json`` if it does not already exist. Otherwise update it accordingly. - -MacOS -~~~~~ - -Move the ``init.json`` file to - -:: - - ~/Library/Containers/com.wearezeta.zclient.mac/Data/Library/Application\ Support/Wire/config/init.json - -if it does not already exist. Otherwise, update it accordingly. - -Linux -~~~~~ - -On Linux the ``init.json`` file should be located in the following directory: - -:: - - $HOME/.config/Wire/config/init.json diff --git a/docs/src/how-to/associate/custom-certificates.rst b/docs/src/how-to/associate/custom-certificates.md similarity index 80% rename from docs/src/how-to/associate/custom-certificates.rst rename to docs/src/how-to/associate/custom-certificates.md index 3a5c15b852..2c52391570 100644 --- a/docs/src/how-to/associate/custom-certificates.rst +++ b/docs/src/how-to/associate/custom-certificates.md @@ -1,10 +1,9 @@ -Custom root certificates -------------------------- +# Custom root certificates In case you have installed wire-server using certificates signed using a custom root CA (certificate authority) which is not trusted by default by browsers and systems, then you need to ensure Wire-clients (on Android, Desktop, iOS, and the Web) trust this root certificate. The following details the procedure for Desktop and Web on Linux/Windows: -https://thomas-leister.de/en/how-to-import-ca-root-certificate/ + For Android and iOS, if you know how to trust custom certificates, please let use know so we can update this documentation. diff --git a/docs/src/how-to/associate/deeplink.md b/docs/src/how-to/associate/deeplink.md new file mode 100644 index 0000000000..d3bfc77e27 --- /dev/null +++ b/docs/src/how-to/associate/deeplink.md @@ -0,0 +1,174 @@ +# Using a Deep Link to connect an App to a Custom Backend + +## Introduction + +Once you have your own wire-server set up and configured, you may want to use a client other than the web interface (webapp). There are a few ways to accomplish this: + +- **Using a Deep Link** (which this page is all about) +- Registering your backend instance with the hosted SaaS backend for re-direction. For which you might need to talk to the folks @ Wire (the company). + +Assumptions: + +- You have wire-server installed and working +- You have a familiarity with JSON files +- You can place a JSON file on an HTTPS supporting web server somewhere your users can reach. + +Supported client apps: + +- iOS +- Android + +```{note} +Wire deeplinks can be used to redirect a mobile (Android, iOS) Wire app to a specific backend URL. Deeplinks have no further ability implemented at this stage. +``` + +## Connecting to a custom backend utilizing a Deep Link + +A deep link is a special link a user can click on after installing wire, but before setting it up. This link instructs their wire client to connect to your wire-server, rather than wire.com. + +### With Added Proxy + +In addition to connect to a custom backend a user can specify a socks proxy to add another layer to the network and make the api calls go through the proxy. + +## From a user's perspective: + +1. First, a user installs the app from the store +2. The user clicks on a deep link, which is formatted similar to: `wire://access/?config=https://eu-north2.mycustomdomain.de/configs/backend1.json` (notice the protocol prefix: `wire://`) +3. The app will ask the user to confirm that they want to connect to a custom backend. If the user cancels, the app exits. +4. Assuming the user did not cancel, the app will download the file `eu-north2.mycustomdomain.de/configs/backend1.json` via HTTPS. If it can't download the file or the file doesn't match the expected structure, the wire client will display an error message (*'sInvalid link'*). +5. The app will memorize the various hosts (REST, websocket, team settings, website, support) specified in the JSON and use those when talking to your backend. +6. In the welcome page of the app, a "pill" (header) is shown at the top, to remind the user that they are now on a custom backend. A button "Show more" shows the URL of where the configuration was fetched from. + +### With Added Proxy + +In addition to the previous points + +7. The app will remember the (proxy host, proxy port, if the proxy need authentication) +8. In the login page the user will see new section to add the proxy credentials if the proxy need authentication + +## From the administrator's (your) perspective: + +You need to host two static files, then let your users know how to connect. There are three options listed (in order of recommendation) for hosting the static files. + +Note on the meaning of the URLs used below: + +`backendURL` + +: Use the backend API entrypoint URL, by convention `https://nginz-https.` + +`backendWSURL` + +: Use the backend Websocket API entrypoint URL, by convention `https://nginz-ssl.` + +`teamsURL` + +: Use the URL to the team settings part of the webapp, by convention `https://teams.` + +`accountsURL` + +: Use the URL to the account pages part of the webapp, by convention `https://account.` + +`blackListURL` + +: is used to disable old versions of Wire clients (mobile apps). It's a prefix URL to which e.g. `/ios` or `/android` is appended. Example URL for the wire.com production servers: `https://clientblacklist.wire.com/prod` and example json files: [android](https://clientblacklist.wire.com/prod/android) and [iPhone](https://clientblacklist.wire.com/prod/ios) . + +`websiteURL` + +: Is used as a basis for a few links within the app pointing to FAQs and troubleshooting pages for end users. You can leave this as `https://wire.com` or host your own alternative pages and point this to your own website with the equivalent pages references from within the app. + +`title` + +: Arbitrary string that may show up in a few places in the app. Should be used as an identifier of the backend servers in question. + +### With Added Proxy + +`apiProxy:host (optional)` + +: Is used to specify a proxy to be added to the network engine, so the API calls will go through it to add more security layer. + +`apiProxy:port (optional)` + +: Is used to specify the port number for the proxy when we create the proxy object in the network layer. + +`apiProxy:needsAuthentication (optional)` + +: Is used to specify if the proxy need an authentication, so we can show the section during the login to enter the proxy credentials. + +#### Host a deeplink together with your Wire installation + +As of release `2.117.0` from `2021-10-29` (see `release notes`), you can configure your deeplink endpoints to match your installation and DNS records (see explanations above) + +```yaml +# override values for wire-server +# (e.g. under ./helm_vars/wire-server/values.yaml) +nginz: + nginx_conf: + deeplink: + endpoints: + backendURL: "https://nginz-https.example.com" + backendWSURL: "https://nginz-ssl.example.com" + teamsURL: "https://teams.example.com" + accountsURL: "https://account.example.com" + blackListURL: "https://clientblacklist.wire.com/prod" + websiteURL: "https://wire.com" + apiProxy: # (optional) + host: "socks5.proxy.com" + port: 1080 + needsAuthentication: true + title: "My Custom Wire Backend" +``` + +(As with any configuration changes, you need to apply them following your usual way of updating configuration (e.g. 'helm upgrade...')) + +Now both static files should become accessible at the backend domain under `/deeplink.json` and `deeplink.html`: + +- `https://nginz-https./deeplink.json` +- `https://nginz-https./deeplink.html` + +#### Host a deeplink using minio (deprecated) + +*If possible, prefer the option in the subsection above or below. This subsection is kept for backwards compatibility.* + +**If you're using minio** installed using the ansible code from [wire-server-deploy](https://github.com/wireapp/wire-server-deploy/blob/master/ansible/), then the [minio ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/master/ansible/minio.yml#L75-L88) (make sure to override these variables) creates a json and a html file in the right format, and makes it accessible at `https://assets./public/deeplink.json` and at `https://assets./public/deeplink.html` + +#### Host a deeplink file using your own web server + +Otherwise you need to create a `.json` file, and host it somewhere users can get to. This `.json` file needs to specify the URLs of your backend. For the production wire server that we host, the JSON would look like: + +```json +{ + "endpoints" : { + "backendURL" : "https://prod-nginz-https.wire.com", + "backendWSURL" : "https://prod-nginz-ssl.wire.com", + "blackListURL" : "https://clientblacklist.wire.com/prod", + "teamsURL" : "https://teams.wire.com", + "accountsURL" : "https://accounts.wire.com", + "websiteURL" : "https://wire.com" + }, + "apiProxy" : { + "host" : "socks5.proxy.com", + "port" : 1080, + "needsAuthentication" : true + }, + "title" : "Production" +} +``` + +**IMPORTANT NOTE:** Clients require **ALL** keys to be present in the JSON file; if some of these keys are irrelevant to your installation (e.g., you don't have a websiteURL) you can leave these values as indicated in the above example. + +There is no requirement for these hosts to be consistent, e.g. the REST endpoint could be `wireapp.pineapple.com` and the team setting `teams.banana.com`. If you have been following this documentation closely, these hosts will likely be consistent in naming, regardless. + +You now need to get a link referring to that `.json` file to your users, prepended with `wire://access/?config=`. For example, you can save the above `.json` file as `https://example.com/wire.json`, and save the following HTML content as `https://example.com/wire.html`: + +```html + + + + link + + +``` + +## Next steps + +Now, you can e.g. email or otherwise provide a link to the deeplink HTML page to your users on their mobile devices, and they can follow the above procedure, by clicking on `link`. diff --git a/docs/src/how-to/associate/deeplink.rst b/docs/src/how-to/associate/deeplink.rst deleted file mode 100644 index 1ef53550b1..0000000000 --- a/docs/src/how-to/associate/deeplink.rst +++ /dev/null @@ -1,174 +0,0 @@ -Using a Deep Link to connect an App to a Custom Backend -======================================================= - -Introduction ------------- - -Once you have your own wire-server set up and configured, you may want to use a client other than the web interface (webapp). There are a few ways to accomplish this: - -- **Using a Deep Link** (which this page is all about) -- Registering your backend instance with the hosted SaaS backend for re-direction. For which you might need to talk to the folks @ Wire (the company). - -Assumptions: - -- You have wire-server installed and working -- You have a familiarity with JSON files -- You can place a JSON file on an HTTPS supporting web server somewhere your users can reach. - -Supported client apps: - -- iOS -- Android - -.. note:: - Wire deeplinks can be used to redirect a mobile (Android, iOS) Wire app to a specific backend URL. Deeplinks have no further ability implemented at this stage. - -Connecting to a custom backend utilizing a Deep Link ----------------------------------------------------- - -A deep link is a special link a user can click on after installing wire, but before setting it up. This link instructs their wire client to connect to your wire-server, rather than wire.com. - -With Added Proxy -~~~~~~~~~~~~~~~~ -In addition to connect to a custom backend a user can specify a socks proxy to add another layer to the network and make the api calls go through the proxy. - -From a user's perspective: --------------------------- - -1. First, a user installs the app from the store -2. The user clicks on a deep link, which is formatted similar to: ``wire://access/?config=https://eu-north2.mycustomdomain.de/configs/backend1.json`` (notice the protocol prefix: ``wire://``) -3. The app will ask the user to confirm that they want to connect to a custom backend. If the user cancels, the app exits. -4. Assuming the user did not cancel, the app will download the file ``eu-north2.mycustomdomain.de/configs/backend1.json`` via HTTPS. If it can't download the file or the file doesn't match the expected structure, the wire client will display an error message (*'sInvalid link'*). -5. The app will memorize the various hosts (REST, websocket, team settings, website, support) specified in the JSON and use those when talking to your backend. -6. In the welcome page of the app, a "pill" (header) is shown at the top, to remind the user that they are now on a custom backend. A button "Show more" shows the URL of where the configuration was fetched from. - -With Added Proxy -~~~~~~~~~~~~~~~~ -In addition to the previous points - -7. The app will remember the (proxy host, proxy port, if the proxy need authentication) -8. In the login page the user will see new section to add the proxy credentials if the proxy need authentication - - -From the administrator's (your) perspective: --------------------------------------------- - -You need to host two static files, then let your users know how to connect. There are three options listed (in order of recommendation) for hosting the static files. - -Note on the meaning of the URLs used below: - -``backendURL`` - Use the backend API entrypoint URL, by convention ``https://nginz-https.`` - -``backendWSURL`` - Use the backend Websocket API entrypoint URL, by convention ``https://nginz-ssl.`` - -``teamsURL`` - Use the URL to the team settings part of the webapp, by convention ``https://teams.`` - -``accountsURL`` - Use the URL to the account pages part of the webapp, by convention ``https://account.`` - -``blackListURL`` - is used to disable old versions of Wire clients (mobile apps). It's a prefix URL to which e.g. `/ios` or `/android` is appended. Example URL for the wire.com production servers: ``https://clientblacklist.wire.com/prod`` and example json files: `android `_ and `iPhone `_ . - -``websiteURL`` - Is used as a basis for a few links within the app pointing to FAQs and troubleshooting pages for end users. You can leave this as ``https://wire.com`` or host your own alternative pages and point this to your own website with the equivalent pages references from within the app. - -``title`` - Arbitrary string that may show up in a few places in the app. Should be used as an identifier of the backend servers in question. - -With Added Proxy -~~~~~~~~~~~~~~~~ - -``apiProxy:host (optional)`` - Is used to specify a proxy to be added to the network engine, so the API calls will go through it to add more security layer. - -``apiProxy:port (optional)`` - Is used to specify the port number for the proxy when we create the proxy object in the network layer. - -``apiProxy:needsAuthentication (optional)`` - Is used to specify if the proxy need an authentication, so we can show the section during the login to enter the proxy credentials. - -Host a deeplink together with your Wire installation -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -As of release ``2.117.0`` from ``2021-10-29`` (see `release notes`), you can configure your deeplink endpoints to match your installation and DNS records (see explanations above) - -.. code:: yaml - - # override values for wire-server - # (e.g. under ./helm_vars/wire-server/values.yaml) - nginz: - nginx_conf: - deeplink: - endpoints: - backendURL: "https://nginz-https.example.com" - backendWSURL: "https://nginz-ssl.example.com" - teamsURL: "https://teams.example.com" - accountsURL: "https://account.example.com" - blackListURL: "https://clientblacklist.wire.com/prod" - websiteURL: "https://wire.com" - apiProxy: # (optional) - host: "socks5.proxy.com" - port: 1080 - needsAuthentication: true - title: "My Custom Wire Backend" - -(As with any configuration changes, you need to apply them following your usual way of updating configuration (e.g. 'helm upgrade...')) - -Now both static files should become accessible at the backend domain under ``/deeplink.json`` and ``deeplink.html``: - -* ``https://nginz-https./deeplink.json`` -* ``https://nginz-https./deeplink.html`` - -Host a deeplink using minio (deprecated) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -*If possible, prefer the option in the subsection above or below. This subsection is kept for backwards compatibility.* - -**If you're using minio** installed using the ansible code from `wire-server-deploy `__, then the `minio ansible playbook `__ (make sure to override these variables) creates a json and a html file in the right format, and makes it accessible at ``https://assets./public/deeplink.json`` and at ``https://assets./public/deeplink.html`` - -Host a deeplink file using your own web server -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Otherwise you need to create a ``.json`` file, and host it somewhere users can get to. This ``.json`` file needs to specify the URLs of your backend. For the production wire server that we host, the JSON would look like: - -.. code:: json - - { - "endpoints" : { - "backendURL" : "https://prod-nginz-https.wire.com", - "backendWSURL" : "https://prod-nginz-ssl.wire.com", - "blackListURL" : "https://clientblacklist.wire.com/prod", - "teamsURL" : "https://teams.wire.com", - "accountsURL" : "https://accounts.wire.com", - "websiteURL" : "https://wire.com" - }, - "apiProxy" : { - "host" : "socks5.proxy.com", - "port" : 1080, - "needsAuthentication" : true - }, - "title" : "Production" - } - -**IMPORTANT NOTE:** Clients require **ALL** keys to be present in the JSON file; if some of these keys are irrelevant to your installation (e.g., you don't have a websiteURL) you can leave these values as indicated in the above example. - -There is no requirement for these hosts to be consistent, e.g. the REST endpoint could be `wireapp.pineapple.com` and the team setting `teams.banana.com`. If you have been following this documentation closely, these hosts will likely be consistent in naming, regardless. - -You now need to get a link referring to that ``.json`` file to your users, prepended with ``wire://access/?config=``. For example, you can save the above ``.json`` file as ``https://example.com/wire.json``, and save the following HTML content as ``https://example.com/wire.html``: - -.. code:: html - - - - - link - - - -Next steps ----------- - -Now, you can e.g. email or otherwise provide a link to the deeplink HTML page to your users on their mobile devices, and they can follow the above procedure, by clicking on ``link``. diff --git a/docs/src/how-to/associate/index.md b/docs/src/how-to/associate/index.md new file mode 100644 index 0000000000..3dba99c8f2 --- /dev/null +++ b/docs/src/how-to/associate/index.md @@ -0,0 +1,10 @@ +# Connecting Wire Clients + +```{toctree} +:glob: true +:maxdepth: 2 + + How to associate a wire client to a custom backend using a deep link + How to use custom root certificates with wire clients + How to use a custom backend with the desktop client +``` diff --git a/docs/src/how-to/associate/index.rst b/docs/src/how-to/associate/index.rst deleted file mode 100644 index 95c7d790f5..0000000000 --- a/docs/src/how-to/associate/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -Connecting Wire Clients -======================= - -.. toctree:: - :maxdepth: 2 - :glob: - - How to associate a wire client to a custom backend using a deep link - How to use custom root certificates with wire clients - How to use a custom backend with the desktop client diff --git a/docs/src/how-to/index.md b/docs/src/how-to/index.md new file mode 100644 index 0000000000..46bf098a9a --- /dev/null +++ b/docs/src/how-to/index.md @@ -0,0 +1,20 @@ +# Administrator's Guide + +Documentation on the installation, deployment and administration of Wire +server components. + +```{warning} +If you already installed Wire by using `poetry`, please refer to the +[old version](https://docs.wire.com/versions/install-with-poetry/how-to/index.html) of +the installation guide. +``` + +```{toctree} +:glob: true +:maxdepth: 2 + + How to install wire-server + How to verify your wire-server installation + How to administrate servers after successful installation + How to connect the public wire clients to your wire-server installation +``` diff --git a/docs/src/how-to/index.rst b/docs/src/how-to/index.rst deleted file mode 100644 index 1ba77e0302..0000000000 --- a/docs/src/how-to/index.rst +++ /dev/null @@ -1,21 +0,0 @@ -Administrator's Guide -===================== - -Documentation on the installation, deployment and administration of Wire -server components. - -.. warning:: - - If you already installed Wire by using ``poetry``, please refer to the - `old version `__ of - the installation guide. - - -.. toctree:: - :maxdepth: 2 - :glob: - - How to install wire-server - How to verify your wire-server installation - How to administrate servers after successful installation - How to connect the public wire clients to your wire-server installation diff --git a/docs/src/how-to/install/ansible-VMs.md b/docs/src/how-to/install/ansible-VMs.md new file mode 100644 index 0000000000..2627eea5d9 --- /dev/null +++ b/docs/src/how-to/install/ansible-VMs.md @@ -0,0 +1,275 @@ +(ansible-vms)= + +# Installing kubernetes and databases on VMs with ansible + +## Introduction + +In a production environment, some parts of the wire-server +infrastructure (such as e.g. cassandra databases) are best configured +outside kubernetes. Additionally, kubernetes can be rapidly set up with +kubespray, via ansible. This section covers installing VMs with ansible. + +## Assumptions + +- A bare-metal setup (no cloud provider) +- All machines run ubuntu 18.04 +- All machines have static IP addresses +- Time on all machines is being kept in sync +- You have the following virtual machines: + +```{eval-rst} +.. include:: includes/vm-table.rst +``` + +(It's up to you how you create these machines - kvm on a bare metal +machine, VM on a cloud provider, real physical machines, etc.) + +## Preparing to run ansible + +(adding-ips-to-hostsini)= + +% TODO: section header unifications/change + +### Adding IPs to hosts.ini + +Go to your checked-out wire-server-deploy/ansible folder: + +``` +cd wire-server-deploy/ansible +``` + +Copy the example hosts file: + +``` +cp hosts.example.ini hosts.ini +``` + +- Edit the hosts.ini, setting the permanent IPs of the hosts you are + setting up wire on. +- On each of the lines declaring a database service node ( + lines in the `[all]` section beginning with cassandra, elasticsearch, + or minio) replace the `ansible_host` values (`X.X.X.X`) with the + IPs of the nodes that you can connect to via SSH. these are the + 'internal' addresses of the machines, not what a client will be + connecting to. +- On each of the lines declaring a kubernetes node (lines in the `[all]` + section starting with 'kubenode') replace the `ip` values + (`Y.Y.Y.Y`) with the IPs which you wish kubernetes to provide + services to clients on, and replace the `ansible_host` values + (`X.X.X.X`) with the IPs of the nodes that you can connect to via + SSH. If the IP you want to provide services on is the same IP that + you use to connect, remove the `ip=Y.Y.Y.Y` completely. +- On each of the lines declaring an `etcd` node (lines in the `[all]` + section starting with etcd), use the same values as you used on the + coresponding kubenode lines in the prior step. +- If you are deploying Restund for voice/video services then on each of the + lines declaring a `restund` node (lines in the `[all]` section + beginning with restund), replace the `ansible_host` values (`X.X.X.X`) + with the IPs of the nodes that you can connect to via SSH. +- Edit the minio variables in `[minio:vars]` (`prefix`, `domain` and `deeplink_title`) + by replacing `example.com` with your own domain. + +There are more settings in this file that we will set in later steps. + +% TODO: remove this warning, and remove the hostname run from the cassandra playbook, or find another way to deal with it. + +```{warning} +Some of these playbooks mess with the hostnames of their targets. You +MUST pick different hosts for playbooks that rename the host. If you +e.g. attempt to run Cassandra and k8s on the same 3 machines, the +hostnames will be overwritten by the second installation playbook, +breaking the first. + +At the least, we know that the cassandra, kubernetes and restund playbooks are +guilty of hostname manipulation. +``` + +### Authentication + +```{eval-rst} +.. include:: includes/ansible-authentication-blob.rst +``` + +## Running ansible to install software on your machines + +You can install kubernetes, cassandra, restund, etc in any order. + +```{note} +In case you only have a single network interface with public IPs but wish to protect inter-database communication, you may use the `tinc.yml` playbook to create a private network interface. In this case, ensure tinc is setup BEFORE running any other playbook. See {ref}`tinc` +``` + +### Installing kubernetes + +Kubernetes is installed via ansible. + +To install kubernetes: + +From `wire-server-deploy/ansible`: + +``` +ansible-playbook -i hosts.ini kubernetes.yml -vv +``` + +When the playbook finishes correctly (which can take up to 20 minutes), you should have a folder `artifacts` containing a file `admin.conf`. Copy this file: + +``` +mkdir -p ~/.kube +cp artifacts/admin.conf ~/.kube/config +``` + +Make sure you can reach the server: + +``` +kubectl version +``` + +should give output similar to this: + +``` +Client Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.7", GitCommit:"1dd5338295409edcfff11505e7bb246f0d325d15", GitTreeState:"clean", BuildDate:"2021-01-13T13:23:52Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"} +Server Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.7", GitCommit:"1dd5338295409edcfff11505e7bb246f0d325d15", GitTreeState:"clean", BuildDate:"2021-01-13T13:15:20Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"} +``` + +### Cassandra + +- If you would like to change the name of the cluster, in your + 'hosts.ini' file, in the `[cassandra:vars]` section, uncomment + the line that changes 'cassandra_clustername', and change default + to be the name you want the cluster to have. +- If you want cassandra nodes to talk to each other on a specific + network interface, rather than the one you use to connect via SSH, + In your 'hosts.ini' file, in the `[all:vars]` section, + uncomment, and set 'cassandra_network_interface' to the name of + the ethernet interface you want cassandra nodes to talk to each + other on. For example: + +```ini +[cassandra:vars] +# cassandra_clustername: default + +[all:vars] +## set to True if using AWS +is_aws_environment = False +## Set the network interface name for cassandra to bind to if you have more than one network interface +cassandra_network_interface = eth0 +``` + +(see +[defaults/main.yml](https://github.com/wireapp/ansible-cassandra/blob/master/defaults/main.yml) +for a full list of variables to change if necessary) + +- Use ansible to deploy Cassandra: + +``` +ansible-playbook -i hosts.ini cassandra.yml -vv +``` + +### ElasticSearch + +- In your 'hosts.ini' file, in the `[all:vars]` section, uncomment + and set 'elasticsearch_network_interface' to the name of the + interface you want elasticsearch nodes to talk to each other on. +- If you are performing an offline install, or for some other reason + are using an APT mirror other than the default to retrieve + elasticsearch-oss packages from, you need to specify that mirror + by setting 'es_apt_key' and 'es_apt_url' in the `[all:vars]` + section of your hosts.ini file. + +```ini +[all:vars] +# default first interface on ubuntu on kvm: +elasticsearch_network_interface=ens3 + +## Set these in order to use an APT mirror other than the default. +# es_apt_key = "https:///linux/ubuntu/gpg" +# es_apt_url = "deb [trusted=yes] https:///apt bionic stable" +``` + +- Use ansible and deploy ElasticSearch: + +``` +ansible-playbook -i hosts.ini elasticsearch.yml -vv +``` + +### Minio + +Minio is used for asset storage, in the case that you are not +running on AWS infrastructure, or feel uncomfortable storing assets +in S3 in encrypted form. If you are using S3 instead of Minio, skip +this step. + +- In your 'hosts.ini' file, in the `[all:vars]` section, make sure + you set the 'minio_network_interface' to the name of the interface + you want minio nodes to talk to each other on. The default from the + playbook is not going to be correct for your machine. For example: +- In your 'hosts.ini' file, in the `[minio:vars]` section, ensure you + set minio_access_key and minio_secret key. +- If you intend to use a `deep link` to configure your clients to + talk to the backend, you need to specify your domain (and optionally + your prefix), so that links to your deep link json file are generated + correctly. By configuring these values, you fill in the blanks of + `https://{{ prefix }}assets.{{ domain }}`. + +```ini +[minio:vars] +minio_access_key = "REPLACE_THIS_WITH_THE_DESIRED_SECRET_KEY" +minio_secret_key = "REPLACE_THIS_WITH_THE_DESIRED_SECRET_KEY" +# if you want to use deep links for client configuration: +#minio_deeplink_prefix = "" +#minio_deeplink_domain = "example.com" + +[all:vars] +# Default first interface on ubuntu on kvm: +minio_network_interface=ens3 +``` + +- Use ansible, and deploy Minio: + +``` +ansible-playbook -i hosts.ini minio.yml -vv +``` + +### Restund + +For instructions on how to install Restund, see {ref}`this page `. + +### IMPORTANT checks + +> After running the above playbooks, it is important to ensure that everything is setup correctly. Please have a look at the post install checks in the section {ref}`checks` + +``` +ansible-playbook -i hosts.ini cassandra-verify-ntp.yml -vv +``` + +### Installing helm charts - prerequisites + +The `helm_external.yml` playbook is used to write or update the IPs of the +databases servers in the `values/-external/values.yaml` files, and +thus make them available for helm and the `-external` charts (e.g. +`cassandra-external`, `elasticsearch-external`, etc). + +Due to limitations in the playbook, make sure that you have defined the +network interfaces for each of the database services in your hosts.ini, +even if they are running on the same interface that you connect to via SSH. +In your hosts.ini under `[all:vars]`: + +```ini +[all:vars] +minio_network_interface = ... +cassandra_network_interface = ... +elasticsearch_network_interface = ... +# if you're using redis external... +redis_network_interface = ... +``` + +Now run the helm_external.yml playbook, to populate network values for helm: + +``` +ansible-playbook -i hosts.ini -vv --diff helm_external.yml +``` + +You can now can install the helm charts. + +#### Next steps for high-available production installation + +Your next step will be {ref}`helm-prod` diff --git a/docs/src/how-to/install/ansible-VMs.rst b/docs/src/how-to/install/ansible-VMs.rst deleted file mode 100644 index 46c818f211..0000000000 --- a/docs/src/how-to/install/ansible-VMs.rst +++ /dev/null @@ -1,277 +0,0 @@ -.. _ansible_vms: - -Installing kubernetes and databases on VMs with ansible -======================================================= - -Introduction ------------- - -In a production environment, some parts of the wire-server -infrastructure (such as e.g. cassandra databases) are best configured -outside kubernetes. Additionally, kubernetes can be rapidly set up with -kubespray, via ansible. This section covers installing VMs with ansible. - -Assumptions ------------ - -- A bare-metal setup (no cloud provider) -- All machines run ubuntu 18.04 -- All machines have static IP addresses -- Time on all machines is being kept in sync -- You have the following virtual machines: - -.. include:: includes/vm-table.rst - -(It's up to you how you create these machines - kvm on a bare metal -machine, VM on a cloud provider, real physical machines, etc.) - -Preparing to run ansible ------------------------- - -.. _adding-ips-to-hostsini: - -.. TODO: section header unifications/change - -Adding IPs to hosts.ini -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Go to your checked-out wire-server-deploy/ansible folder:: - - cd wire-server-deploy/ansible - -Copy the example hosts file:: - - cp hosts.example.ini hosts.ini - -- Edit the hosts.ini, setting the permanent IPs of the hosts you are - setting up wire on. -- On each of the lines declaring a database service node ( - lines in the ``[all]`` section beginning with cassandra, elasticsearch, - or minio) replace the ``ansible_host`` values (``X.X.X.X``) with the - IPs of the nodes that you can connect to via SSH. these are the - 'internal' addresses of the machines, not what a client will be - connecting to. -- On each of the lines declaring a kubernetes node (lines in the ``[all]`` - section starting with 'kubenode') replace the ``ip`` values - (``Y.Y.Y.Y``) with the IPs which you wish kubernetes to provide - services to clients on, and replace the ``ansible_host`` values - (``X.X.X.X``) with the IPs of the nodes that you can connect to via - SSH. If the IP you want to provide services on is the same IP that - you use to connect, remove the ``ip=Y.Y.Y.Y`` completely. -- On each of the lines declaring an ``etcd`` node (lines in the ``[all]`` - section starting with etcd), use the same values as you used on the - coresponding kubenode lines in the prior step. -- If you are deploying Restund for voice/video services then on each of the - lines declaring a ``restund`` node (lines in the ``[all]`` section - beginning with restund), replace the ``ansible_host`` values (``X.X.X.X``) - with the IPs of the nodes that you can connect to via SSH. -- Edit the minio variables in ``[minio:vars]`` (``prefix``, ``domain`` and ``deeplink_title``) - by replacing ``example.com`` with your own domain. - -There are more settings in this file that we will set in later steps. - -.. TODO: remove this warning, and remove the hostname run from the cassandra playbook, or find another way to deal with it. - -.. warning:: - - Some of these playbooks mess with the hostnames of their targets. You - MUST pick different hosts for playbooks that rename the host. If you - e.g. attempt to run Cassandra and k8s on the same 3 machines, the - hostnames will be overwritten by the second installation playbook, - breaking the first. - - At the least, we know that the cassandra, kubernetes and restund playbooks are - guilty of hostname manipulation. - -Authentication -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. include:: includes/ansible-authentication-blob.rst - -Running ansible to install software on your machines ------------------------------------------------------ - -You can install kubernetes, cassandra, restund, etc in any order. - -.. note:: - - In case you only have a single network interface with public IPs but wish to protect inter-database communication, you may use the ``tinc.yml`` playbook to create a private network interface. In this case, ensure tinc is setup BEFORE running any other playbook. See :ref:`tinc` - -Installing kubernetes -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Kubernetes is installed via ansible. - -To install kubernetes: - -From ``wire-server-deploy/ansible``:: - - ansible-playbook -i hosts.ini kubernetes.yml -vv - -When the playbook finishes correctly (which can take up to 20 minutes), you should have a folder ``artifacts`` containing a file ``admin.conf``. Copy this file:: - - mkdir -p ~/.kube - cp artifacts/admin.conf ~/.kube/config - -Make sure you can reach the server:: - - kubectl version - -should give output similar to this:: - - Client Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.7", GitCommit:"1dd5338295409edcfff11505e7bb246f0d325d15", GitTreeState:"clean", BuildDate:"2021-01-13T13:23:52Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"} - Server Version: version.Info{Major:"1", Minor:"19", GitVersion:"v1.19.7", GitCommit:"1dd5338295409edcfff11505e7bb246f0d325d15", GitTreeState:"clean", BuildDate:"2021-01-13T13:15:20Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"} - -Cassandra -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- If you would like to change the name of the cluster, in your - 'hosts.ini' file, in the ``[cassandra:vars]`` section, uncomment - the line that changes 'cassandra_clustername', and change default - to be the name you want the cluster to have. -- If you want cassandra nodes to talk to each other on a specific - network interface, rather than the one you use to connect via SSH, - In your 'hosts.ini' file, in the ``[all:vars]`` section, - uncomment, and set 'cassandra_network_interface' to the name of - the ethernet interface you want cassandra nodes to talk to each - other on. For example: - -.. code:: ini - - [cassandra:vars] - # cassandra_clustername: default - - [all:vars] - ## set to True if using AWS - is_aws_environment = False - ## Set the network interface name for cassandra to bind to if you have more than one network interface - cassandra_network_interface = eth0 - -(see -`defaults/main.yml `__ -for a full list of variables to change if necessary) - -- Use ansible to deploy Cassandra: - -:: - - ansible-playbook -i hosts.ini cassandra.yml -vv - -ElasticSearch -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- In your 'hosts.ini' file, in the ``[all:vars]`` section, uncomment - and set 'elasticsearch_network_interface' to the name of the - interface you want elasticsearch nodes to talk to each other on. -- If you are performing an offline install, or for some other reason - are using an APT mirror other than the default to retrieve - elasticsearch-oss packages from, you need to specify that mirror - by setting 'es_apt_key' and 'es_apt_url' in the ``[all:vars]`` - section of your hosts.ini file. - -.. code:: ini - - [all:vars] - # default first interface on ubuntu on kvm: - elasticsearch_network_interface=ens3 - - ## Set these in order to use an APT mirror other than the default. - # es_apt_key = "https:///linux/ubuntu/gpg" - # es_apt_url = "deb [trusted=yes] https:///apt bionic stable" - -- Use ansible and deploy ElasticSearch: - -:: - - ansible-playbook -i hosts.ini elasticsearch.yml -vv - -Minio -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Minio is used for asset storage, in the case that you are not -running on AWS infrastructure, or feel uncomfortable storing assets -in S3 in encrypted form. If you are using S3 instead of Minio, skip -this step. - - -- In your 'hosts.ini' file, in the ``[all:vars]`` section, make sure - you set the 'minio_network_interface' to the name of the interface - you want minio nodes to talk to each other on. The default from the - playbook is not going to be correct for your machine. For example: -- In your 'hosts.ini' file, in the ``[minio:vars]`` section, ensure you - set minio_access_key and minio_secret key. -- If you intend to use a ``deep link`` to configure your clients to - talk to the backend, you need to specify your domain (and optionally - your prefix), so that links to your deep link json file are generated - correctly. By configuring these values, you fill in the blanks of - ``https://{{ prefix }}assets.{{ domain }}``. - -.. code:: ini - - [minio:vars] - minio_access_key = "REPLACE_THIS_WITH_THE_DESIRED_SECRET_KEY" - minio_secret_key = "REPLACE_THIS_WITH_THE_DESIRED_SECRET_KEY" - # if you want to use deep links for client configuration: - #minio_deeplink_prefix = "" - #minio_deeplink_domain = "example.com" - - [all:vars] - # Default first interface on ubuntu on kvm: - minio_network_interface=ens3 - -- Use ansible, and deploy Minio: - -:: - - ansible-playbook -i hosts.ini minio.yml -vv - -Restund -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -For instructions on how to install Restund, see :ref:`this page `. - - -IMPORTANT checks -^^^^^^^^^^^^^^^^ - - After running the above playbooks, it is important to ensure that everything is setup correctly. Please have a look at the post install checks in the section :ref:`checks` - -:: - - ansible-playbook -i hosts.ini cassandra-verify-ntp.yml -vv - -Installing helm charts - prerequisites -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The ``helm_external.yml`` playbook is used to write or update the IPs of the -databases servers in the ``values/-external/values.yaml`` files, and -thus make them available for helm and the ``-external`` charts (e.g. -``cassandra-external``, ``elasticsearch-external``, etc). - -Due to limitations in the playbook, make sure that you have defined the -network interfaces for each of the database services in your hosts.ini, -even if they are running on the same interface that you connect to via SSH. -In your hosts.ini under ``[all:vars]``: - -.. code:: ini - - [all:vars] - minio_network_interface = ... - cassandra_network_interface = ... - elasticsearch_network_interface = ... - # if you're using redis external... - redis_network_interface = ... - - -Now run the helm_external.yml playbook, to populate network values for helm: - -:: - - ansible-playbook -i hosts.ini -vv --diff helm_external.yml - -You can now can install the helm charts. - -Next steps for high-available production installation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Your next step will be :ref:`helm_prod` diff --git a/docs/src/how-to/install/ansible-authentication.md b/docs/src/how-to/install/ansible-authentication.md new file mode 100644 index 0000000000..9943d7514b --- /dev/null +++ b/docs/src/how-to/install/ansible-authentication.md @@ -0,0 +1,63 @@ +(ansible-authentication)= + +# Manage ansible authentication settings + +Ansible works best if + +- you use ssh keys, not passwords +- the user you use to ssh is either `root` or can become `root` (can run `sudo su -`) without entering a password + +However, other options are possible, see below: + +## How to use password authentication when you ssh to a machine with ansible + +If, instead of using ssh keys to ssh to a remote machine, you want to use passwords: + +``` +sudo apt install sshpass +``` + +- in hosts.ini, uncomment the 'ansible_user = ...' line, and change '...' to the user you want to login as. +- in hosts.ini, uncomment the 'ansible_ssh_pass = ...' line, and change '...' to the password for the user you are logging in as. +- in hosts.ini, uncomment the 'ansible_become_pass = ...' line, and change the ... to the password you'd enter to sudo. + +## Configuring SSH keys + +(from ) If you +want a bit higher security, you can copy SSH keys between the machine +you are administrating with, and the machines you are managing with +ansible. + +- Create an SSH key. + +``` +ssh-keygen -t rsa +``` + +- Install your SSH key on each of the machines you are managing with + ansible, so that you can SSH into them without a password: + +``` +ssh-copy-id -i ~/.ssh/id_rsa.pub $USERNAME@$IP +``` + +Replace `$USERNAME` with the username of the account you set up when +you installed the machine. + +## Sudo without password + +Ansible can be configured to use a password for switching from the +unpriviledged \$USERNAME to the root user. This involves having the +password lying about, so has security problems. If you want ansible to +not be prompted for any administrative command (a different security +problem!): + +- As root on each of the nodes, add the following line at the end of + the /etc/sudoers file: + +``` + ALL=(ALL) NOPASSWD:ALL +``` + +Replace `` with the username of the account +you set up when you installed the machine. diff --git a/docs/src/how-to/install/ansible-authentication.rst b/docs/src/how-to/install/ansible-authentication.rst deleted file mode 100644 index 8e549fb64c..0000000000 --- a/docs/src/how-to/install/ansible-authentication.rst +++ /dev/null @@ -1,66 +0,0 @@ -.. _ansible-authentication: - -Manage ansible authentication settings -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Ansible works best if - -* you use ssh keys, not passwords -* the user you use to ssh is either ``root`` or can become ``root`` (can run ``sudo su -``) without entering a password - -However, other options are possible, see below: - - -How to use password authentication when you ssh to a machine with ansible -'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - -If, instead of using ssh keys to ssh to a remote machine, you want to use passwords:: - - sudo apt install sshpass - -* in hosts.ini, uncomment the 'ansible_user = ...' line, and change '...' to the user you want to login as. -* in hosts.ini, uncomment the 'ansible_ssh_pass = ...' line, and change '...' to the password for the user you are logging in as. -* in hosts.ini, uncomment the 'ansible_become_pass = ...' line, and change the ... to the password you'd enter to sudo. - -Configuring SSH keys -'''''''''''''''''''' - -(from https://linoxide.com/how-tos/ssh-login-with-public-key/) If you -want a bit higher security, you can copy SSH keys between the machine -you are administrating with, and the machines you are managing with -ansible. - -- Create an SSH key. - -:: - - ssh-keygen -t rsa - -- Install your SSH key on each of the machines you are managing with - ansible, so that you can SSH into them without a password: - -:: - - ssh-copy-id -i ~/.ssh/id_rsa.pub $USERNAME@$IP - -Replace ``$USERNAME`` with the username of the account you set up when -you installed the machine. - -Sudo without password -''''''''''''''''''''' - -Ansible can be configured to use a password for switching from the -unpriviledged $USERNAME to the root user. This involves having the -password lying about, so has security problems. If you want ansible to -not be prompted for any administrative command (a different security -problem!): - -- As root on each of the nodes, add the following line at the end of - the /etc/sudoers file: - -:: - - ALL=(ALL) NOPASSWD:ALL - -Replace ```` with the username of the account -you set up when you installed the machine. diff --git a/docs/src/how-to/install/ansible-tinc.md b/docs/src/how-to/install/ansible-tinc.md new file mode 100644 index 0000000000..294c1faa99 --- /dev/null +++ b/docs/src/how-to/install/ansible-tinc.md @@ -0,0 +1,54 @@ +(tinc)= + +# tinc + +Installing [tinc mesh vpn](http://tinc-vpn.org/) is *optional and +experimental*. It allows having a private network interface `vpn0` on +the target VMs. + +```{warning} +We currently only use tinc for test clusters and have not made sure if the default settings it comes with provide adequate security to protect your data. If using tinc and the following tinc.yml playbook, make your own checks first! +``` + +```{note} +Ensure to run the tinc.yml playbook first if you use tinc, before +other playbooks. +``` + +From `wire-server-deploy/ansible`, where you created a `hosts.ini` file. + +- Add a `vpn_ip=Z.Z.Z.Z` item to each entry in the hosts file with a + (fresh) IP range if you wish to use tinc. +- Add a group `vpn`: + +```ini +# this is a minimal example +[all] +server1 ansible_host=X.X.X.X vpn_ip=10.10.1.XXX +server2 ansible_host=X.X.X.X vpn_ip=10.10.1.YYY + +[cassandra] +server1 +server2 + +[vpn:children] +cassandra +# add other server groups here as necessary +``` + +Also ensure subsequent playbooks make use of the newly-created interface by setting: + +```ini +[all:vars] +minio_network_interface = vpn0 +cassandra_network_interface = vpn0 +elasticsearch_network_interface = vpn0 +redis_network_interface = vpn0 +``` + +Configure the physical network interface inside tinc.yml if it is not +`eth0`. Then: + +``` +ansible-playbook -i hosts.ini tinc.yml -vv +``` diff --git a/docs/src/how-to/install/ansible-tinc.rst b/docs/src/how-to/install/ansible-tinc.rst deleted file mode 100644 index ca5698b7ab..0000000000 --- a/docs/src/how-to/install/ansible-tinc.rst +++ /dev/null @@ -1,54 +0,0 @@ -.. _tinc: - -tinc ----- - -Installing `tinc mesh vpn `__ is *optional and -experimental*. It allows having a private network interface ``vpn0`` on -the target VMs. - -.. warning:: - We currently only use tinc for test clusters and have not made sure if the default settings it comes with provide adequate security to protect your data. If using tinc and the following tinc.yml playbook, make your own checks first! - -.. note:: - - Ensure to run the tinc.yml playbook first if you use tinc, before - other playbooks. - -From ``wire-server-deploy/ansible``, where you created a `hosts.ini` file. - -- Add a ``vpn_ip=Z.Z.Z.Z`` item to each entry in the hosts file with a - (fresh) IP range if you wish to use tinc. -- Add a group ``vpn``: - -.. code:: ini - - # this is a minimal example - [all] - server1 ansible_host=X.X.X.X vpn_ip=10.10.1.XXX - server2 ansible_host=X.X.X.X vpn_ip=10.10.1.YYY - - [cassandra] - server1 - server2 - - [vpn:children] - cassandra - # add other server groups here as necessary - -Also ensure subsequent playbooks make use of the newly-created interface by setting: - -.. code:: ini - - [all:vars] - minio_network_interface = vpn0 - cassandra_network_interface = vpn0 - elasticsearch_network_interface = vpn0 - redis_network_interface = vpn0 - -Configure the physical network interface inside tinc.yml if it is not -``eth0``. Then: - -:: - - ansible-playbook -i hosts.ini tinc.yml -vv diff --git a/docs/src/how-to/install/aws-prod.md b/docs/src/how-to/install/aws-prod.md new file mode 100644 index 0000000000..0359d98d71 --- /dev/null +++ b/docs/src/how-to/install/aws-prod.md @@ -0,0 +1,36 @@ +(aws-prod)= + +# Configuring AWS and wire-server (production) components + +## Introduction + +The following procedures are for configuring wire-server on top of AWS. They are not required to use wire-server in AWS, but they may be a good idea, depending on the AWS features you are comfortable using. + +## Using real AWS services for SNS + +AWS SNS is required to send notification events to clients via [FCM](https://firebase.google.com/docs/cloud-messaging/)/[APNS](https://developer.apple.com/notifications/) . These notification channels are useable only for clients that are connected from the public internet. Using these vendor provided communication channels allows client devices (phones) running a wire client to save a considerable amount of battery life, compared to the websockets approach. + +For details on how to set up SNS in cooperation with us (We - Wire - will proxy push notifications through Amazon for you), see {ref}`push-sns`. + +## Using real AWS services for SES / SQS + +AWS SES and SQS are used for delivering emails to clients, and for receiving notifications of bounced emails. SQS is also used internally, in order to facilitate batch user deletion. + +FIXME: detail this step. + +## Using real AWS services for S3 + +S3-style services are used by cargohold to store encrypted files that users are sharing amongst each other, profile pics, etc. + +Defining S3 services: +Create an S3 bucket in the region you are hosting your wire servers in. For example terraform code, see: + +The S3 bucket you create should have it's contents downloadable from the internet, as clients get the content directly from S3, rather than having to talk through the wire backend. + +Using S3 services: + +There are three values in the `cargohold.config.aws` section of your 'values.yaml' that you need to provide while deploying wire-server: + +- s3Bucket: the name of the S3 bucket you have created. +- s3Endpoint: the S3 service endpoint cargohold should talk to, to place files in the S3 bucket. On AWS, this takes the form of: `https://.s3-.amazonaws.com`. +- s3DownloadEndpoint: The URL base that clients should use to get contents from the S3 bucket. On AWS, this takes the form of: `https://s3..amazonaws.com`. diff --git a/docs/src/how-to/install/aws-prod.rst b/docs/src/how-to/install/aws-prod.rst deleted file mode 100644 index 0cf147bc20..0000000000 --- a/docs/src/how-to/install/aws-prod.rst +++ /dev/null @@ -1,39 +0,0 @@ -.. _aws_prod: - -Configuring AWS and wire-server (production) components -======================================================= - -Introduction ------------- - -The following procedures are for configuring wire-server on top of AWS. They are not required to use wire-server in AWS, but they may be a good idea, depending on the AWS features you are comfortable using. - -Using real AWS services for SNS --------------------------------------------------------- -AWS SNS is required to send notification events to clients via `FCM `__/`APNS `__ . These notification channels are useable only for clients that are connected from the public internet. Using these vendor provided communication channels allows client devices (phones) running a wire client to save a considerable amount of battery life, compared to the websockets approach. - -For details on how to set up SNS in cooperation with us (We - Wire - will proxy push notifications through Amazon for you), see :ref:`pushsns`. - -Using real AWS services for SES / SQS ---------------------------------------------- -AWS SES and SQS are used for delivering emails to clients, and for receiving notifications of bounced emails. SQS is also used internally, in order to facilitate batch user deletion. - -FIXME: detail this step. - -Using real AWS services for S3 ------------------------------- -S3-style services are used by cargohold to store encrypted files that users are sharing amongst each other, profile pics, etc. - -Defining S3 services: -Create an S3 bucket in the region you are hosting your wire servers in. For example terraform code, see: https://github.com/wireapp/wire-server-deploy/tree/develop/terraform/modules/aws-cargohold-asset-storage - -The S3 bucket you create should have it's contents downloadable from the internet, as clients get the content directly from S3, rather than having to talk through the wire backend. - -Using S3 services: - -There are three values in the ``cargohold.config.aws`` section of your 'values.yaml' that you need to provide while deploying wire-server: - -* s3Bucket: the name of the S3 bucket you have created. -* s3Endpoint: the S3 service endpoint cargohold should talk to, to place files in the S3 bucket. On AWS, this takes the form of: ``https://.s3-.amazonaws.com``. -* s3DownloadEndpoint: The URL base that clients should use to get contents from the S3 bucket. On AWS, this takes the form of: ``https://s3..amazonaws.com``. - diff --git a/docs/src/how-to/install/configuration-options.md b/docs/src/how-to/install/configuration-options.md new file mode 100644 index 0000000000..647ac5f0ee --- /dev/null +++ b/docs/src/how-to/install/configuration-options.md @@ -0,0 +1,1048 @@ +(configuration-options)= + +# Part 3 - configuration options in a production setup + +This contains instructions to configure specific aspects of your production setup depending on your needs. + +Depending on your use-case and requirements, you may need to +configure none, or only a subset of the following sections. + +## Redirect some traffic through a http(s) proxy + +In case you wish to use http(s) proxies, you can add a configuration like this to the wire-server services in question: + +Assuming your proxy can be reached from within Kubernetes at `http://proxy:8080`, add the following for each affected service (e.g. `gundeck`) to your Helm overrides in `values/wire-server/values.yaml` : + +```yaml +gundeck: + # ... + config: + # ... + proxy: + httpProxy: "http://proxy:8080" + httpsProxy: "http://proxy:8080" + noProxyList: + - "localhost" + - "127.0.0.1" + - "10.0.0.0/8" + - "elasticsearch-external" + - "cassandra-external" + - "redis-ephemeral" + - "fake-aws-sqs" + - "fake-aws-dynamodb" + - "fake-aws-sns" + - "brig" + - "cargohold" + - "galley" + - "gundeck" + - "proxy" + - "spar" + - "federator" + - "cannon" + - "cannon-0.cannon.default" + - "cannon-1.cannon.default" + - "cannon-2.cannon.default" +``` + +Depending on your setup, you may need to repeat this for the other services like `brig` as well. + +(push-sns)= + +## Enable push notifications using the public appstore / playstore mobile Wire clients + +1. You need to get in touch with us. Please talk to sales or customer support - see +2. If a contract agreement has been reached, we can set up a separate AWS account for you containing the necessary AWS SQS/SNS setup to route push notifications through to the mobile apps. We will then forward some configuration / access credentials that looks like: + +```yaml +gundeck: + config: + aws: + account: "" + arnEnv: "" + queueName: "-gundeck-events" + region: "" + snsEndpoint: "https://sns..amazonaws.com" + sqsEndpoint: "https://sqs..amazonaws.com" + secrets: + awsKeyId: "" + awsSecretKey: "" +``` + +To make use of those, first test the credentials are correct, e.g. using the `aws` command-line tool (for more information on how to configure credentials, please refer to the [official docs](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-precedence)): + +``` +AWS_REGION= +AWS_ACCESS_KEY_ID=<...> +AWS_SECRET_ACCESS_KEY=<...> +ENV= #e.g staging + +aws sqs get-queue-url --queue-name "$ENV-gundeck-events" +``` + +You should get a result like this: + +``` +{ + "QueueUrl": "https://.queue.amazonaws.com//-gundeck-events" +} +``` + +Then add them to your gundeck configuration overrides. + +Keys below `gundeck.config` belong into `values/wire-server/values.yaml`: + +```yaml +gundeck: + # ... + config: + aws: + queueName: # e.g. staging-gundeck-events + account: # , e.g. 123456789 + region: # e.g. eu-central-1 + snsEndpoint: # e.g. https://sns.eu-central-1.amazonaws.com + sqsEndpoint: # e.g. https://sqs.eu-central-1.amazonaws.com + arnEnv: # e.g. staging - this must match the environment name (first part of queueName) +``` + +Keys below `gundeck.secrets` belong into `values/wire-server/secrets.yaml`: + +```yaml +gundeck: + # ... + secrets: + awsKeyId: CHANGE-ME + awsSecretKey: CHANGE-ME +``` + +After making this change and applying it to gundeck (ensure gundeck pods have restarted to make use of the updated configuration - that should happen automatically), make sure to reset the push token on any mobile devices that you may have in use. + +Next, if you want, you can stop using the `fake-aws-sns` pods in case you ran them before: + +```yaml +# inside override values/fake-aws/values.yaml +fake-aws-sns: + enabled: false +``` + +## Controlling the speed of websocket draining during cannon pod replacement + +The 'cannon' component is responsible for persistent websocket connections. +Normally the default options would slowly and gracefully drain active websocket +connections over a maximum of `(amount of cannon replicas * 30 seconds)` during +the deployment of a new wire-server version. This will lead to a very brief +interruption for Wire clients when their client has to re-connect on the +websocket. + +You're not expected to need to change these settings. + +The following options are only relevant during the restart of cannon itself. +During a restart of nginz or ingress-controller, all websockets will get +severed. If this is to be avoided, see section {ref}`separate-websocket-traffic` + +`drainOpts`: Drain websockets in a controlled fashion when cannon receives a +SIGTERM or SIGINT (this happens when a pod is terminated e.g. during rollout +of a new version). Instead of waiting for connections to close on their own, +the websockets are now severed at a controlled pace. This allows for quicker +rollouts of new versions. + +There is no way to entirely disable this behaviour, two extreme examples below + +- the quickest way to kill cannon is to set `gracePeriodSeconds: 1` and + `minBatchSize: 100000` which would sever all connections immediately; but it's + not recommended as you could DDoS yourself by forcing all active clients to + reconnect at the same time. With this, cannon pod replacement takes only 1 + second per pod. +- the slowest way to roll out a new version of cannon without severing websocket + connections for a long time is to set `minBatchSize: 1`, + `millisecondsBetweenBatches: 86400000` and `gracePeriodSeconds: 86400` + which would lead to one single websocket connection being closed immediately, + and all others only after 1 day. With this, cannon pod replacement takes a + full day per pod. + +```yaml +# overrides for wire-server/values.yaml +cannon: + drainOpts: + # The following defaults drain a minimum of 400 connections/second + # for a total of 10000 over 25 seconds + # (if cannon holds more connections, draining will happen at a faster pace) + gracePeriodSeconds: 25 + millisecondsBetweenBatches: 50 + minBatchSize: 20 +``` + +## Control nginz upstreams (routes) into the Kubernetes cluster + +Open unterminated upstreams (routes) into the Kubernetes cluster are a potential +security issue. To prevent this, there are fine-grained settings in the nginz +configuration defining which upstreams should exist. + +### Default upstreams + +Upstreams for services that exist in (almost) every Wire installation are +enabled by default. These are: + +- `brig` +- `cannon` +- `cargohold` +- `galley` +- `gundeck` +- `spar` + +For special setups (as e.g. described in [separate-websocket-traffic]) the +upstreams of these services can be ignored (disabled) with the setting +`nginz.nginx_conf.ignored_upstreams`. + +The most common example is to disable the upstream of `cannon`: + +```yaml +nginz: + nginx_conf: + ignored_upstreams: ["cannon"] +``` + +### Optional upstreams + +There are some services that are usually not deployed on most Wire installations +or are specific to the Wire cloud: + +- `ibis` +- `galeb` +- `calling-test` +- `proxy` + +The upstreams for those are disabled by default and can be enabled by the +setting `nginz.nginx_conf.enabled_extra_upstreams`. + +The most common example is to enable the (extra) upstream of `proxy`: + +```yaml +nginz: + nginx_conf: + enabled_extra_upstreams: ["proxy"] +``` + +### Combining default and extra upstream configurations + +Default and extra upstream configurations are independent of each other. I.e. +`nginz.nginx_conf.ignored_upstreams` and +`nginz.nginx_conf.enabled_extra_upstreams` can be combined in the same +configuration: + +```yaml +nginz: + nginx_conf: + ignored_upstreams: ["cannon"] + enabled_extra_upstreams: ["proxy"] +``` + +(separate-websocket-traffic)= + +## Separate incoming websocket network traffic from the rest of the https traffic + +By default, incoming network traffic for websockets comes through these network +hops: + +Internet -> LoadBalancer -> kube-proxy -> nginx-ingress-controller -> nginz -> cannon + +In order to have graceful draining of websockets when something gets restarted, as it is not easily +possible to implement the graceful draining on nginx-ingress-controller or nginz by itself, there is +a configuration option to get the following network hops: + +Internet -> separate LoadBalancer for cannon only -> kube-proxy -> \[nginz->cannon (2 containers in the same pod)\] + +```yaml +# example on AWS when using cert-manager for TLS certificates and external-dns for DNS records +# (see wire-server/charts/cannon/values.yaml for more possible options) + +# in your wire-server/values.yaml overrides: +cannon: + service: + nginz: + enabled: true + hostname: "nginz-ssl.example.com" + externalDNS: + enabled: true + certManager: + enabled: true + annotations: + service.beta.kubernetes.io/aws-load-balancer-type: "nlb" + service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" +nginz: + nginx_conf: + ignored_upstreams: ["cannon"] +``` + +```yaml +# in your wire-server/secrets.yaml overrides: +cannon: + secrets: + nginz: + zAuth: + publicKeys: ... # same values as in nginz.secrets.zAuth.publicKeys +``` + +```yaml +# in your nginx-ingress-services/values.yaml overrides: +websockets: + enabled: false +``` + +## Blocking creation of personal users, new teams + +### In Brig + +There are some unauthenticated end-points that allow arbitrary users on the open internet to do things like create a new team. This is desired in the cloud, but if you run an on-prem setup that is open to the world, you may want to block this. + +Brig has a server option for this: + +```yaml +optSettings: + setRestrictUserCreation: true +``` + +If `setRestrictUserCreation` is `true`, creating new personal users or new teams on your instance from outside your backend installation is impossible. (If you want to be more technical: requests to `/register` that create a new personal account or a new team are answered with `403 forbidden`.) + +On instances with restricted user creation, the site operator with access to the internal REST API can still circumvent the restriction: just log into a brig service pod via ssh and follow the steps in `hack/bin/create_test_team_admins.sh.` + +```{note} +Once the creation of new users and teams has been disabled, it will still be possible to use the [team creation process](https://support.wire.com/hc/en-us/articles/115003858905-Create-a-team) (enter the new team name, email, password, etc), but it will fail/refuse creation late in the creation process (after the «Create team» button is clicked). +``` + +### In the WebApp + +Another way of disabling user registration is by this webapp setting, in `values.yaml`, changing this value from `true` to `false`: + +```yaml +FEATURE_ENABLE_ACCOUNT_REGISTRATION: "false" +``` + +```{note} +If you only disable the creation of users in the webapp, but do not do so in Brig/the backend, a malicious user would be able to use the API to create users, so make sure to disable both. +``` + +## You may want + +- more server resources to ensure + [high-availability](#persistence-and-high-availability) +- an email/SMTP server to send out registration emails +- depending on your required functionality, you may or may not need an + [AWS account](https://aws.amazon.com/). See details about + limitations without an AWS account in the following sections. +- one or more people able to maintain the installation +- official support by Wire ([contact us](https://wire.com/pricing/)) + +```{warning} +As of 2020-08-10, the documentation sections below are partially out of date and need to be updated. +``` + +## Metrics/logging + +- {ref}`monitoring` +- {ref}`logging` + +## SMTP server + +**Assumptions**: none + +**Provides**: + +- full control over email sending + +**You need**: + +- SMTP credentials (to allow for email sending; prerequisite for + registering users and running the smoketest) + +**How to configure**: + +- *if using a gmail account, ensure to enable* ['less secure + apps'](https://support.google.com/accounts/answer/6010255?hl=en) +- Add user, SMTP server, connection type to `values/wire-server`'s + values file under `brig.config.smtp` +- Add password in `secrets/wire-server`'s secrets file under + `brig.secrets.smtpPassword` + +## Load balancer on bare metal servers + +**Assumptions**: + +- You installed kubernetes on bare metal servers or virtual machines + that can bind to a public IP address. +- **If you are using AWS or another cloud provider, see**[Creating a + cloudprovider-based load + balancer](#load-balancer-on-cloud-provider)**instead** + +**Provides**: + +- Allows using a provided Load balancer for incoming traffic +- SSL termination is done on the ingress controller +- You can access your wire-server backend with given DNS names, over + SSL and from anywhere in the internet + +**You need**: + +- A kubernetes node with a *public* IP address (or internal, if you do + not plan to expose the Wire backend over the Internet but we will + assume you are using a public IP address) + +- DNS records for the different exposed addresses (the ingress depends + on the usage of virtual hosts), namely: + + - `nginz-https.` + - `nginz-ssl.` + - `assets.` + - `webapp.` + - `account.` + - `teams.` + +- A wildcard certificate for the different hosts (`*.`) - we + assume you want to do SSL termination on the ingress controller + +**Caveats**: + +- Note that there can be only a *single* load balancer, otherwise your + cluster might become + [unstable](https://metallb.universe.tf/installation/) + +**How to configure**: + +``` +cp values/metallb/demo-values.example.yaml values/metallb/demo-values.yaml +cp values/nginx-ingress-services/demo-values.example.yaml values/nginx-ingress-services/demo-values.yaml +cp values/nginx-ingress-services/demo-secrets.example.yaml values/nginx-ingress-services/demo-secrets.yaml +``` + +- Adapt `values/metallb/demo-values.yaml` to provide a list of public + IP address CIDRs that your kubernetes nodes can bind to. +- Adapt `values/nginx-ingress-services/demo-values.yaml` with correct URLs +- Put your TLS cert and key into + `values/nginx-ingress-services/demo-secrets.yaml`. + +Install `metallb` (for more information see the +[docs](https://metallb.universe.tf)): + +```sh +helm upgrade --install --namespace metallb-system metallb wire/metallb \ + -f values/metallb/demo-values.yaml \ + --wait --timeout 1800 +``` + +Install `nginx-ingress-[controller,services]`: + +:: +: helm upgrade --install --namespace demo demo-nginx-ingress-controller wire/nginx-ingress-controller + + : --wait + + helm upgrade --install --namespace demo demo-nginx-ingress-services wire/nginx-ingress-services + + : -f values/nginx-ingress-services/demo-values.yaml -f values/nginx-ingress-services/demo-secrets.yaml --wait + +Now, create DNS records for the URLs configured above. + +## Load Balancer on cloud-provider + +### AWS + +[Upload the required +certificates](https://aws.amazon.com/premiumsupport/knowledge-center/import-ssl-certificate-to-iam/). +Create and configure `values/aws-ingress/demo-values.yaml` from the +examples. + +``` +helm upgrade --install --namespace demo demo-aws-ingress wire/aws-ingress \ + -f values/aws-ingress/demo-values.yaml \ + --wait +``` + +To give your load balancers public DNS names, create and edit +`values/external-dns/demo-values.yaml`, then run +[external-dns](https://github.com/helm/charts/tree/master/stable/external-dns): + +``` +helm repo update +helm upgrade --install --namespace demo demo-external-dns stable/external-dns \ + --version 1.7.3 \ + -f values/external-dns/demo-values.yaml \ + --wait +``` + +Things to note about external-dns: + +- There can only be a single external-dns chart installed (one per + kubernetes cluster, not one per namespace). So if you already have + one running for another namespace you probably don't need to do + anything. +- You have to add the appropriate IAM permissions to your cluster (see + the + [README](https://github.com/helm/charts/tree/master/stable/external-dns)). +- Alternatively, use the AWS route53 console. + +### Other cloud providers + +This information is not yet available. If you'd like to contribute by +adding this information for your cloud provider, feel free to read the +[contributing guidelines](https://github.com/wireapp/wire-server-deploy/blob/master/CONTRIBUTING.md) and open a PR. + +## Real AWS services + +**Assumptions**: + +- You installed kubernetes and wire-server on AWS + +**Provides**: + +- Better availability guarantees and possibly better functionality of + AWS services such as SQS and dynamoDB. +- You can use ELBs in front of nginz for higher availability. +- instead of using a smtp server and connect with SMTP, you may use + SES. See configuration of brig and the `useSES` toggle. + +**You need**: + +- An AWS account + +**How to configure**: + +- Instead of using fake-aws charts, you need to set up the respective + services in your account, create queues, tables etc. Have a look at + the fake-aws-\* charts; you'll need to replicate a similar setup. + + - Once real AWS resources are created, adapt the configuration in + the values and secrets files for wire-server to use real endpoints + and real AWS keys. Look for comments including + `if using real AWS`. + +- Creating AWS resources in a way that is easy to create and delete + could be done using either [terraform](https://www.terraform.io/) + or [pulumi](https://pulumi.io/). If you'd like to contribute by + creating such automation, feel free to read the [contributing + guidelines](https://github.com/wireapp/wire-server-deploy/blob/master/CONTRIBUTING.md) and open a PR. + +## Persistence and high-availability + +Currently, due to the way kubernetes and cassandra +[interact](https://github.com/kubernetes/kubernetes/issues/28969), +cassandra cannot reliably be installed on kubernetes. Some people have +tried, e.g. [this +project](https://github.com/instaclustr/cassandra-operator) though at +the time of writing (Nov 2018), this does not yet work as advertised. We +recommend therefore to install cassandra, (possibly also elasticsearch +and redis) separately, i.e. outside of kubernetes (using 3 nodes each). + +For further higher-availability: + +- scale your kubernetes cluster to have separate etcd and master nodes + (3 nodes each) +- use 3 instead of 1 replica of each wire-server chart + +## Security + +For a production deployment, you should, as a minimum: + +- Ensure traffic between kubernetes nodes, etcd and databases are + confined to a private network +- Ensure kubernetes API is unreachable from the public internet (e.g. + put behind VPN/bastion host or restrict IP range) to prevent + [kubernetes + vulnerabilities](https://www.cvedetails.com/vulnerability-list/vendor_id-15867/product_id-34016/Kubernetes-Kubernetes.html) + from affecting you +- Ensure your operating systems get security updates automatically +- Restrict ssh access / harden sshd configuration +- Ensure no other pods with public access than the main ingress are + deployed on your cluster, since, in the current setup, pods have + access to etcd values (and thus any secrets stored there, including + secrets from other pods) +- Ensure developers encrypt any secrets.yaml files + +Additionally, you may wish to build, sign, and host your own docker +images to have increased confidence in those images. We haved "signed +container images" on our roadmap. + +## Sign up with a phone number (Sending SMS) + +**Provides**: + +- Registering accounts with a phone number + +**You need**: + +- a [Nexmo](https://www.nexmo.com/) account +- a [Twilio](https://www.twilio.com/) account + +**How to configure**: + +See the `brig` chart for configuration. + +(rd-party-proxying)= + +## 3rd-party proxying + +You need Giphy/Google/Spotify/Soundcloud API keys (if you want to +support previews by proxying these services) + +See the `proxy` chart for configuration. + +## Routing traffic to other namespaces via nginz + +If you have some components running in namespaces different from nginz. For +instance, the billing service (`ibis`) could be deployed to a separate +namespace, say `integrations`. But it still needs to get traffic via +`nginz`. When this is needed, the helm config can be adjusted like this: + +```yaml +# in your wire-server/values.yaml overrides: +nginz: + nginx_conf: + upstream_namespace: + ibis: integrations +``` + +## Marking an installation as self-hosted + +In case your wire installation is self-hosted (on-premise, demo installs), it needs to be aware that it is through a configuration option. As of release chart 4.15.0, `"true"` is the default behavior, and nothing needs to be done. + +If that option is not set, team-settings will prompt users about "wire for free" and associated functions. + +With that option set, all payment related functionality is disabled. + +The option is `IS_SELF_HOSTED`, and you set it in your `values.yaml` file (originally a copy of `prod-values.example.yaml` found in `wire-server-deploy/values/wire-server/`). + +In case of a demo install, replace `prod` with `demo`. + +First set the option under the `team-settings` section, `envVars` sub-section: + +```yaml +# NOTE: Only relevant if you want team-settings +team-settings: + envVars: + IS_SELF_HOSTED: "true" +``` + +Second, also set the option under the `account-pages` section: + +```yaml +# NOTE: Only relevant if you want account-pages +account-pages: + envVars: + IS_SELF_HOSTED: "true" +``` + +(auth-cookie-config)= + +## Configuring authentication cookie throttling + +Authentication cookies and the related throttling mechanism is described in the *Client API documentation*: +{ref}`login-cookies` + +The maximum number of cookies per account and type is defined by the brig option +`setUserCookieLimit`. Its default is `32`. + +Throttling is configured by the brig option `setUserCookieThrottle`. It is an +object that contains two fields: + +`stdDev` + +: The minimal standard deviation of cookie creation timestamps in + Seconds. (Default: `3000`, + [Wikipedia: Standard deviation](https://en.wikipedia.org/wiki/Standard_deviation)) + +`retryAfter` + +: Wait time in Seconds when `stdDev` is violated. (Default: `86400`) + +The default values are fine for most use cases. (Generally, you don't have to +configure them for your installation.) + +Condensed example: + +```yaml +brig: + optSettings: + setUserCookieLimit: 32 + setUserCookieThrottle: + stdDev: 3000 + retryAfter: 86400 +``` + +## Configuring searchability + +You can configure how search is limited or not based on user membership in a given team. + +There are two types of searches based on the direction of search: + +- **Inbound** searches mean that somebody is searching for you. Configuring the inbound search visibility means that you (or some admin) can configure whether others can find you or not. +- **Outbound** searches mean that you are searching for somebody. Configuring the outbound search visibility means that some admin can configure whether you can find other users or not. + +There are different types of matches: + +- **Exact handle** search means that the user is found only if the search query is exactly the user handle (e.g. searching for `mc` will find `@mc` but not `@mccaine`). This search returns zero or one results. +- **Full text** search means that the user is found if the search query contains some subset of the user display name and handle. (e.g. the query `mar` will find `Marco C`, `Omar`, `@amaro`) + +### Searching users on the same backend + +Search visibility is controlled by three parameters on the backend: + +- A team outbound configuration flag, `TeamSearchVisibility` with possible values `SearchVisibilityStandard`, `SearchVisibilityNoNameOutsideTeam` + + - `SearchVisibilityStandard` means that the user can find other people outside of the team, if the searched-person inbound search allows it + - `SearchVisibilityNoNameOutsideTeam` means that the user can not find any user outside the team by full text search (but exact handle search still works) + +- A team inbound configuration flag, `SearchVisibilityInbound` with possible values `SearchableByOwnTeam`, `SearchableByAllTeams` + + - `SearchableByOwnTeam` means that the user can be found only by users in their own team. + - `SearchableByAllTeams` means that the user can be found by users in any/all teams. + +- A server configuration flag `searchSameTeamOnly` with possible values true, false. + + - `Note`: For the same backend, this affects inbound and outbound searches (simply because all teams will be subject to this behavior) + - Setting this to `true` means that the all teams on that backend can only find users that belong to their team + +These flag are set on the backend and the clients do not need to be aware of them. + +The flags will influence the behavior of the search API endpoint; clients will only need to parse the results, that are already filtered for them by the backend. + +#### Table of possible outcomes + +```{eval-rst} ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Is search-er (`uA`) in team (tA)? | Is search-ed (`uB`) in a team? | Backend flag `searchSameTeamOnly` | Team `tA`'s flag `TeamSearchVisibility` | Team tB's flag `SearchVisibilityInbound` | Result of exact search for `uB` | Result of full-text search for `uB` | ++====================================+=================================+====================================+==========================================+===========================================+==================================+======================================+ +| **Search within the same team** | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | Yes, the same team `tA` | Irrelevant | Irrelevant | Irrelevant | Found | Found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| **Outbound search unrestricted** | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityStandard` | `SearchableByAllTeams` | Found | Found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityStandard` | `SearchableByOwnTeam` | Found | Not found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| **Outbound search restricted** | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | Yes, another team tB | true | Irrelevant | Irrelevant | Not found | Not found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityNoNameOutsideTeam` | Irrelevant | Found | Not found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +| Yes, `tA` | No | false | `SearchVisibilityNoNameOutsideTeam` | There’s no team B | Found | Not found | ++------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ +``` + +#### Changing the configuration on the server + +To change the `searchSameTeamOnly` setting on the backend, edit the `values.yaml.gotmpl` file for the wire-server chart at this nested level of the configuration: + +```yaml +brig: + # ... + config: + # ... + optSettings: + # ... + setSearchSameTeamOnly: true +``` + +If `setSearchSameTeamOnly` is set to `true` then `TeamSearchVisibility` is forced be in the `SearchVisibilityNoNameOutsideTeam` setting for all teams. + +#### Changing the default configuration for all teams + +If `setSearchSameTeamOnly` is set to `false` (or missing from the configuration) then the default value `TeamSearchVisibility` can be configured at this level of the configuration of the `value.yaml.gotmpl` file of the wire-server chart: + +```yaml +galley: + #... + config: + #... + settings: + #... + featureFlags: + #... + teamSearchVisibility: enabled-by-default +``` + +This default value applies to all teams for which no explicit configuration of the `TeamSearchVisibility` has been set. + +### Searching users on another (federated) backend + +For federated search the table above does not apply, see following table. + +```{note} +Incoming federated searches (i.e. searches from one backend to another) are considered always as being performed from a team user, even if they are performed from a personal user. + +This is because the incoming search request does not carry the information whether the user performing the search was in a team or not. + +So we have to make one assumption, and we assume that they were in a team. +``` + +Allowing search is done at the backend configuration level by the sysadmin: + +- Outbound search restrictions (`searchSameTeamOnly`, `TeamSearchVisibility`) do not apply to federated searches + +- A configuration setting `FederatedUserSearchPolicy` per federating domain with these possible values: + + - `no_search` The federating backend is not allowed to search any users (either by exact handle or full-text). + - `exact_handle_search` The federating backend may only search by exact handle + - `full_search` The federating backend may search users by full text search on display name and handle. The search search results are additionally affected by `SearchVisibilityInbound` setting of each team on the backend. + +- The `SearchVisibilityInbound` setting applies. Since the default value for teams is `SearchableByOwnTeam` this means that for a team to be full-text searchable by users on a federating backend both + + - `FederatedUserSearchPolicy` needs to be set to to full_search for the federating backend + - Any team that wants to be full-text searchable needs to be set to `SearchableByAllTeams` + +The configuration value `FederatedUserSearchPolicy` is per federated domain, e.g. in the values of the wire-server chart: + +```yaml +brig: + config: + optSettings: + setFederationDomainConfigs: + - domain: a.example.com + search_policy: no_search + - domain: a.example.com + search_policy: full_search +``` + +#### Table of possible outcomes + +In the following table, user `uA` on backend A is searching for user `uB` on team `tB` on backend B. + +Any of the flags set for searching users on the same backend are ignored. + +It’s worth nothing that if two users are on two separate backend, they are also guaranteed to be on two separate teams, as teams can not spread across backends. + +| Who is searching | Backend B flag `FederatedUserSearchPolicy` | Team `tB`'s flag `SearchVisibilityInbound` | Result of exact search for `uB` | Result of full-text search for `uB` | +| ---------------------- | ------------------------------------------ | ------------------------------------------ | ------------------------------- | ----------------------------------- | +| user `uA` on backend A | `no_search` | Irrelevant | Not found | Not found | +| user `uA` on backend A | `exact_handle_search` | Irrelevant | Found | Not found | +| user `uA` on backend A | `full_search` | SearchableByOwnTeam | Found | Not found | +| user `uA` on backend A | `full_search` | SearchableByAllTeams | Found | Found | + +### Changing the settings for a given team + +If you need to change searchabilility for a specific team (rather than the entire backend, as above), you need to make specific calls to the API. + +#### Team searchVisibility + +The team flag `searchVisibility` affects the outbound search of user searches. + +If it is set to `no-name-outside-team` for a team then all users of that team will no longer be able to find users that are not part of their team when searching. + +This also includes finding other users by by providing their exact handle. By default it is set to `standard`, which doesn't put any additional restrictions to outbound searches. + +The setting can be changed via endpoint (for more details on how to make the API calls with `curl`, read further): + +``` +GET /teams/{tid}/search-visibility + -- Shows the current TeamSearchVisibility value for the given team + +PUT /teams/{tid}/search-visibility + -- Set specific search visibility for the team + +pull-down-menu "body": + "standard" + "no-name-outside-team" +``` + +The team feature flag `teamSearchVisibility` determines whether it is allowed to change the `searchVisibility` setting or not. + +The default is `disabled-by-default`. + +```{note} +Whenever this feature setting is disabled the `searchVisibility` will be reset to standard. +``` + +The default setting that applies to all teams on the instance can be defined at configuration + +```yaml +settings: + featureFlags: + teamSearchVisibility: disabled-by-default # or enabled-by-default +``` + +#### TeamFeature searchVisibilityInbound + +The team feature flag `searchVisibilityInbound` affects if the team's users are searchable by users from other teams. + +The default setting is `searchable-by-own-team` which hides users from search results by users from other teams. + +If it is set to `searchable-by-all-teams` then users of this team may be included in the results of search queries by other users. + +```{note} +The configuration of this flag does not affect search results when the search query matches the handle exactly. + +If the handle is provdided then any user on the instance can find users. +``` + +This team feature flag can only by toggled by site-administrators with direct access to the galley instance (for more details on how to make the API calls with `curl`, read further): + +``` +PUT /i/teams/{tid}/features/search-visibility-inbound +``` + +With JSON body: + +```json +{"status": "enabled"} +``` + +or + +```json +{"status": "disabled"} +``` + +Where `enabled` is equivalent to `searchable-by-all-teams` and `disabled` is equivalent to `searchable-by-own-team`. + +The default setting that applies to all teams on the instance can be defined at configuration. + +```yaml +searchVisibilityInbound: + defaults: + status: enabled # OR disabled +``` + +Individual teams can overwrite the default setting with API calls as per above. + +#### Making the API calls + +To make API calls to set an explicit configuration for\` TeamSearchVisibilityInbound\` per team, you first need to know the Team ID, which can be found in the team settings app. + +It is an `UUID` which has format like this `dcbedf9a-af2a-4f43-9fd5-525953a919e1`. + +In the following we will be using this Team ID as an example, please replace it with your own team id. + +Next find the name of a `galley` pod by looking at the output of running this command: + +```sh +kubectl -n wire get pods +``` + +The output will look something like this: + +``` +... +galley-5f4787fdc7-9l64n ... +galley-migrate-data-lzz5j ... +... +``` + +Select any of the galley pods, for example we will use `galley-5f4787fdc7-9l64n`. + +Next, set up a port-forwarding from your local machine's port `9000` to the galley's port `8080` by running: + +```sh +kubectl port-forward -n wire galley-5f4787fdc7-9l64n 9000:8080 +``` + +Keep this command running until the end of these instuctions. + +Please run the following commands in a seperate terminal while keeping the terminal which establishes the port-forwarding open. + +To see team's current setting run: + +```sh +curl -XGET http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound + +# {"lockStatus":"unlocked","status":"disabled"} +``` + +Where `disabled` corresponds to `SearchableByOwnTeam` and enabled corresponds to `SearchableByAllTeams`. + +To change the `TeamSearchVisibilityInbound` to `SearchableByAllTeams` for the team run: + +```sh +curl -XPUT -H 'Content-Type: application/json' -d "{\"status\": \"enabled\"}" http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound +``` + +To change the TeamSearchVisibilityInbound to SearchableByOwnTeam for the team run: + +```sh +curl -XPUT -H 'Content-Type: application/json' -d "{\"status\": \"disabled\"}" http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound +``` + +## Configuring classified domains + +As a backend administrator, if you want to control which other backends (identified by their domain) are "classified", + +change the following `galley` configuration in the `value.yaml.gotmpl` file of the wire-server chart: + +```yaml +galley: + replicaCount: 1 + config: + ... + featureFlags: + ... + classifiedDomains: + status: enabled + config: + domains: ["domain-that-is-classified.link"] + ... +``` + +This is not only a `backend` configuration, but also a `team` configuration/feature. + +This means that different combinations of configurations will have different results. + +Here is a table to navigate the possible configurations: + +| Backend Config enabled/disabled | Backend Config Domains | Team Config enabled/disabled | Team Config Domains | User's view | +| ------------------------------- | ---------------------------------------------- | ---------------------------- | ----------------------- | -------------------------------- | +| Enabled | \[domain1.example.com\] | Not configured | Not configured | Enabled, \[domain1.example.com\] | +| Enabled | \[domain1.example.com\]\[domain1.example.com\] | Enabled | Not configured | Enabled, \[domain1.example.com\] | +| Enabled | \[domain1.example.com\] | Enabled | \[domain2.example.com\] | Enabled, Undefined | +| Enabled | \[domain1.example.com\] | Disabled | Anything | Undefined | +| Disabled | Anything | Not configured | Not configured | Disabled, no domains | +| Disabled | Anything | Enabled | \[domain2.example.com\] | Undefined | + +The table assumes the following: + +- When backend level config says that this feature is enabled, it is illegal to not specify domains at the backend level. +- When backend level config says that this feature is disabled, the list of domains is ignored. +- When team level feature is disabled, the accompanying domains are ignored. + +## S3 Addressing Style + +S3 can either by addressed in path style, i.e. +`https:////`, or vhost style, i.e. +`https://./`. AWS's S3 offering has deprecated +path style addressing for S3 and completely disabled it for buckets created +after 30 Sep 2020: + + +However other object storage providers (specially self-deployed ones like MinIO) +may not support vhost style addressing yet (or ever?). Users of such buckets +should configure this option to "path": + +```yaml +cargohold: + aws: + s3AddressingStyle: path +``` + +Installations using S3 service provided by AWS, should use "auto", this option +will ensure that vhost style is only used when it is possible to construct a +valid hostname from the bucket name and the bucket name doesn't contain a '.'. +Having a '.' in the bucket name causes TLS validation to fail, hence it is not +used by default: + +```yaml +cargohold: + aws: + s3AddressingStyle: auto +``` + +Using "virtual" as an option is only useful in situations where vhost style +addressing must be used even if it is not possible to construct a valid hostname +from the bucket name or the S3 service provider can ensure correct certificate +is issued for bucket which contain one or more '.'s in the name: + +```yaml +cargohold: + aws: + s3AddressingStyle: virtual +``` + +When this option is unspecified, wire-server defaults to path style addressing +to ensure smooth transition for older deployments. diff --git a/docs/src/how-to/install/configuration-options.rst b/docs/src/how-to/install/configuration-options.rst deleted file mode 100644 index 869dc6c603..0000000000 --- a/docs/src/how-to/install/configuration-options.rst +++ /dev/null @@ -1,1106 +0,0 @@ -.. _configuration_options: - -Part 3 - configuration options in a production setup -==================================================================== - -This contains instructions to configure specific aspects of your production setup depending on your needs. - -Depending on your use-case and requirements, you may need to -configure none, or only a subset of the following sections. - -Redirect some traffic through a http(s) proxy ---------------------------------------------- - -In case you wish to use http(s) proxies, you can add a configuration like this to the wire-server services in question: - -Assuming your proxy can be reached from within Kubernetes at ``http://proxy:8080``, add the following for each affected service (e.g. ``gundeck``) to your Helm overrides in ``values/wire-server/values.yaml`` : - -.. code:: yaml - - gundeck: - # ... - config: - # ... - proxy: - httpProxy: "http://proxy:8080" - httpsProxy: "http://proxy:8080" - noProxyList: - - "localhost" - - "127.0.0.1" - - "10.0.0.0/8" - - "elasticsearch-external" - - "cassandra-external" - - "redis-ephemeral" - - "fake-aws-sqs" - - "fake-aws-dynamodb" - - "fake-aws-sns" - - "brig" - - "cargohold" - - "galley" - - "gundeck" - - "proxy" - - "spar" - - "federator" - - "cannon" - - "cannon-0.cannon.default" - - "cannon-1.cannon.default" - - "cannon-2.cannon.default" - -Depending on your setup, you may need to repeat this for the other services like ``brig`` as well. - -.. _pushsns: - -Enable push notifications using the public appstore / playstore mobile Wire clients ------------------------------------------------------------------------------------ - -1. You need to get in touch with us. Please talk to sales or customer support - see https://wire.com -2. If a contract agreement has been reached, we can set up a separate AWS account for you containing the necessary AWS SQS/SNS setup to route push notifications through to the mobile apps. We will then forward some configuration / access credentials that looks like: - -.. code:: yaml - - gundeck: - config: - aws: - account: "" - arnEnv: "" - queueName: "-gundeck-events" - region: "" - snsEndpoint: "https://sns..amazonaws.com" - sqsEndpoint: "https://sqs..amazonaws.com" - secrets: - awsKeyId: "" - awsSecretKey: "" - -To make use of those, first test the credentials are correct, e.g. using the ``aws`` command-line tool (for more information on how to configure credentials, please refer to the `official docs `__): - -.. code:: - - AWS_REGION= - AWS_ACCESS_KEY_ID=<...> - AWS_SECRET_ACCESS_KEY=<...> - ENV= #e.g staging - - aws sqs get-queue-url --queue-name "$ENV-gundeck-events" - -You should get a result like this: - -.. code:: - - { - "QueueUrl": "https://.queue.amazonaws.com//-gundeck-events" - } - -Then add them to your gundeck configuration overrides. - -Keys below ``gundeck.config`` belong into ``values/wire-server/values.yaml``: - -.. code:: yaml - - gundeck: - # ... - config: - aws: - queueName: # e.g. staging-gundeck-events - account: # , e.g. 123456789 - region: # e.g. eu-central-1 - snsEndpoint: # e.g. https://sns.eu-central-1.amazonaws.com - sqsEndpoint: # e.g. https://sqs.eu-central-1.amazonaws.com - arnEnv: # e.g. staging - this must match the environment name (first part of queueName) - -Keys below ``gundeck.secrets`` belong into ``values/wire-server/secrets.yaml``: - -.. code:: yaml - - gundeck: - # ... - secrets: - awsKeyId: CHANGE-ME - awsSecretKey: CHANGE-ME - - -After making this change and applying it to gundeck (ensure gundeck pods have restarted to make use of the updated configuration - that should happen automatically), make sure to reset the push token on any mobile devices that you may have in use. - -Next, if you want, you can stop using the `fake-aws-sns` pods in case you ran them before: - -.. code:: yaml - - # inside override values/fake-aws/values.yaml - fake-aws-sns: - enabled: false - -Controlling the speed of websocket draining during cannon pod replacement -------------------------------------------------------------------------- - -The 'cannon' component is responsible for persistent websocket connections. -Normally the default options would slowly and gracefully drain active websocket -connections over a maximum of ``(amount of cannon replicas * 30 seconds)`` during -the deployment of a new wire-server version. This will lead to a very brief -interruption for Wire clients when their client has to re-connect on the -websocket. - -You're not expected to need to change these settings. - -The following options are only relevant during the restart of cannon itself. -During a restart of nginz or ingress-controller, all websockets will get -severed. If this is to be avoided, see section :ref:`separate-websocket-traffic` - -``drainOpts``: Drain websockets in a controlled fashion when cannon receives a -SIGTERM or SIGINT (this happens when a pod is terminated e.g. during rollout -of a new version). Instead of waiting for connections to close on their own, -the websockets are now severed at a controlled pace. This allows for quicker -rollouts of new versions. - -There is no way to entirely disable this behaviour, two extreme examples below - -* the quickest way to kill cannon is to set ``gracePeriodSeconds: 1`` and - ``minBatchSize: 100000`` which would sever all connections immediately; but it's - not recommended as you could DDoS yourself by forcing all active clients to - reconnect at the same time. With this, cannon pod replacement takes only 1 - second per pod. -* the slowest way to roll out a new version of cannon without severing websocket - connections for a long time is to set ``minBatchSize: 1``, - ``millisecondsBetweenBatches: 86400000`` and ``gracePeriodSeconds: 86400`` - which would lead to one single websocket connection being closed immediately, - and all others only after 1 day. With this, cannon pod replacement takes a - full day per pod. - -.. code:: yaml - - # overrides for wire-server/values.yaml - cannon: - drainOpts: - # The following defaults drain a minimum of 400 connections/second - # for a total of 10000 over 25 seconds - # (if cannon holds more connections, draining will happen at a faster pace) - gracePeriodSeconds: 25 - millisecondsBetweenBatches: 50 - minBatchSize: 20 - - -Control nginz upstreams (routes) into the Kubernetes cluster ------------------------------------------------------------- - -Open unterminated upstreams (routes) into the Kubernetes cluster are a potential -security issue. To prevent this, there are fine-grained settings in the nginz -configuration defining which upstreams should exist. - -Default upstreams -^^^^^^^^^^^^^^^^^ - -Upstreams for services that exist in (almost) every Wire installation are -enabled by default. These are: - -- ``brig`` -- ``cannon`` -- ``cargohold`` -- ``galley`` -- ``gundeck`` -- ``spar`` - -For special setups (as e.g. described in separate-websocket-traffic_) the -upstreams of these services can be ignored (disabled) with the setting -``nginz.nginx_conf.ignored_upstreams``. - -The most common example is to disable the upstream of ``cannon``: - -.. code:: yaml - - nginz: - nginx_conf: - ignored_upstreams: ["cannon"] - - -Optional upstreams -^^^^^^^^^^^^^^^^^^ - -There are some services that are usually not deployed on most Wire installations -or are specific to the Wire cloud: - -- ``ibis`` -- ``galeb`` -- ``calling-test`` -- ``proxy`` - -The upstreams for those are disabled by default and can be enabled by the -setting ``nginz.nginx_conf.enabled_extra_upstreams``. - -The most common example is to enable the (extra) upstream of ``proxy``: - -.. code:: yaml - - nginz: - nginx_conf: - enabled_extra_upstreams: ["proxy"] - - -Combining default and extra upstream configurations -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Default and extra upstream configurations are independent of each other. I.e. -``nginz.nginx_conf.ignored_upstreams`` and -``nginz.nginx_conf.enabled_extra_upstreams`` can be combined in the same -configuration: - -.. code:: yaml - - nginz: - nginx_conf: - ignored_upstreams: ["cannon"] - enabled_extra_upstreams: ["proxy"] - - -.. _separate-websocket-traffic: - -Separate incoming websocket network traffic from the rest of the https traffic -------------------------------------------------------------------------------- - -By default, incoming network traffic for websockets comes through these network -hops: - -Internet -> LoadBalancer -> kube-proxy -> nginx-ingress-controller -> nginz -> cannon - -In order to have graceful draining of websockets when something gets restarted, as it is not easily -possible to implement the graceful draining on nginx-ingress-controller or nginz by itself, there is -a configuration option to get the following network hops: - -Internet -> separate LoadBalancer for cannon only -> kube-proxy -> [nginz->cannon (2 containers in the same pod)] - -.. code:: yaml - - # example on AWS when using cert-manager for TLS certificates and external-dns for DNS records - # (see wire-server/charts/cannon/values.yaml for more possible options) - - # in your wire-server/values.yaml overrides: - cannon: - service: - nginz: - enabled: true - hostname: "nginz-ssl.example.com" - externalDNS: - enabled: true - certManager: - enabled: true - annotations: - service.beta.kubernetes.io/aws-load-balancer-type: "nlb" - service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" - nginz: - nginx_conf: - ignored_upstreams: ["cannon"] - -.. code:: yaml - - # in your wire-server/secrets.yaml overrides: - cannon: - secrets: - nginz: - zAuth: - publicKeys: ... # same values as in nginz.secrets.zAuth.publicKeys - -.. code:: yaml - - # in your nginx-ingress-services/values.yaml overrides: - websockets: - enabled: false - - -Blocking creation of personal users, new teams ----------------------------------------------- - -In Brig -~~~~~~~ - -There are some unauthenticated end-points that allow arbitrary users on the open internet to do things like create a new team. This is desired in the cloud, but if you run an on-prem setup that is open to the world, you may want to block this. - -Brig has a server option for this: - -.. code:: yaml - - optSettings: - setRestrictUserCreation: true - -If `setRestrictUserCreation` is `true`, creating new personal users or new teams on your instance from outside your backend installation is impossible. (If you want to be more technical: requests to `/register` that create a new personal account or a new team are answered with `403 forbidden`.) - -On instances with restricted user creation, the site operator with access to the internal REST API can still circumvent the restriction: just log into a brig service pod via ssh and follow the steps in `hack/bin/create_test_team_admins.sh.` - -.. note:: - Once the creation of new users and teams has been disabled, it will still be possible to use the `team creation process `__ (enter the new team name, email, password, etc), but it will fail/refuse creation late in the creation process (after the «Create team» button is clicked). - -In the WebApp -~~~~~~~~~~~~~ - -Another way of disabling user registration is by this webapp setting, in `values.yaml`, changing this value from `true` to `false`: - -.. code:: yaml - - FEATURE_ENABLE_ACCOUNT_REGISTRATION: "false" - -.. note:: - If you only disable the creation of users in the webapp, but do not do so in Brig/the backend, a malicious user would be able to use the API to create users, so make sure to disable both. - -You may want ------------- - -- more server resources to ensure - `high-availability <#persistence-and-high-availability>`__ -- an email/SMTP server to send out registration emails -- depending on your required functionality, you may or may not need an - `AWS account `__. See details about - limitations without an AWS account in the following sections. -- one or more people able to maintain the installation -- official support by Wire (`contact us `__) - -.. warning:: - - As of 2020-08-10, the documentation sections below are partially out of date and need to be updated. - -Metrics/logging ---------------- - -* :ref:`monitoring` -* :ref:`logging` - -SMTP server ------------ - -**Assumptions**: none - -**Provides**: - -- full control over email sending - -**You need**: - -- SMTP credentials (to allow for email sending; prerequisite for - registering users and running the smoketest) - -**How to configure**: - -- *if using a gmail account, ensure to enable* `'less secure - apps' `__ -- Add user, SMTP server, connection type to ``values/wire-server``'s - values file under ``brig.config.smtp`` -- Add password in ``secrets/wire-server``'s secrets file under - ``brig.secrets.smtpPassword`` - -Load balancer on bare metal servers ------------------------------------ - -**Assumptions**: - -- You installed kubernetes on bare metal servers or virtual machines - that can bind to a public IP address. -- **If you are using AWS or another cloud provider, see**\ `Creating a - cloudprovider-based load - balancer <#load-balancer-on-cloud-provider>`__\ **instead** - -**Provides**: - -- Allows using a provided Load balancer for incoming traffic -- SSL termination is done on the ingress controller -- You can access your wire-server backend with given DNS names, over - SSL and from anywhere in the internet - -**You need**: - -- A kubernetes node with a *public* IP address (or internal, if you do - not plan to expose the Wire backend over the Internet but we will - assume you are using a public IP address) -- DNS records for the different exposed addresses (the ingress depends - on the usage of virtual hosts), namely: - - - ``nginz-https.`` - - ``nginz-ssl.`` - - ``assets.`` - - ``webapp.`` - - ``account.`` - - ``teams.`` - -- A wildcard certificate for the different hosts (``*.``) - we - assume you want to do SSL termination on the ingress controller - -**Caveats**: - -- Note that there can be only a *single* load balancer, otherwise your - cluster might become - `unstable `__ - -**How to configure**: - -:: - - cp values/metallb/demo-values.example.yaml values/metallb/demo-values.yaml - cp values/nginx-ingress-services/demo-values.example.yaml values/nginx-ingress-services/demo-values.yaml - cp values/nginx-ingress-services/demo-secrets.example.yaml values/nginx-ingress-services/demo-secrets.yaml - -- Adapt ``values/metallb/demo-values.yaml`` to provide a list of public - IP address CIDRs that your kubernetes nodes can bind to. -- Adapt ``values/nginx-ingress-services/demo-values.yaml`` with correct URLs -- Put your TLS cert and key into - ``values/nginx-ingress-services/demo-secrets.yaml``. - -Install ``metallb`` (for more information see the -`docs `__): - -.. code:: sh - - helm upgrade --install --namespace metallb-system metallb wire/metallb \ - -f values/metallb/demo-values.yaml \ - --wait --timeout 1800 - -Install ``nginx-ingress-[controller,services]``: - -:: - helm upgrade --install --namespace demo demo-nginx-ingress-controller wire/nginx-ingress-controller \ - --wait - - helm upgrade --install --namespace demo demo-nginx-ingress-services wire/nginx-ingress-services \ - -f values/nginx-ingress-services/demo-values.yaml \ - -f values/nginx-ingress-services/demo-secrets.yaml \ - --wait - -Now, create DNS records for the URLs configured above. - - -Load Balancer on cloud-provider -------------------------------- - -AWS -~~~ - -`Upload the required -certificates `__. -Create and configure ``values/aws-ingress/demo-values.yaml`` from the -examples. - -:: - - helm upgrade --install --namespace demo demo-aws-ingress wire/aws-ingress \ - -f values/aws-ingress/demo-values.yaml \ - --wait - -To give your load balancers public DNS names, create and edit -``values/external-dns/demo-values.yaml``, then run -`external-dns `__: - -:: - - helm repo update - helm upgrade --install --namespace demo demo-external-dns stable/external-dns \ - --version 1.7.3 \ - -f values/external-dns/demo-values.yaml \ - --wait - -Things to note about external-dns: - -- There can only be a single external-dns chart installed (one per - kubernetes cluster, not one per namespace). So if you already have - one running for another namespace you probably don't need to do - anything. -- You have to add the appropriate IAM permissions to your cluster (see - the - `README `__). -- Alternatively, use the AWS route53 console. - -Other cloud providers -~~~~~~~~~~~~~~~~~~~~~ - -This information is not yet available. If you'd like to contribute by -adding this information for your cloud provider, feel free to read the -`contributing guidelines `__ and open a PR. - -Real AWS services ------------------ - -**Assumptions**: - -- You installed kubernetes and wire-server on AWS - -**Provides**: - -- Better availability guarantees and possibly better functionality of - AWS services such as SQS and dynamoDB. -- You can use ELBs in front of nginz for higher availability. -- instead of using a smtp server and connect with SMTP, you may use - SES. See configuration of brig and the ``useSES`` toggle. - -**You need**: - -- An AWS account - -**How to configure**: - -- Instead of using fake-aws charts, you need to set up the respective - services in your account, create queues, tables etc. Have a look at - the fake-aws-\* charts; you'll need to replicate a similar setup. - - - Once real AWS resources are created, adapt the configuration in - the values and secrets files for wire-server to use real endpoints - and real AWS keys. Look for comments including - ``if using real AWS``. - -- Creating AWS resources in a way that is easy to create and delete - could be done using either `terraform `__ - or `pulumi `__. If you'd like to contribute by - creating such automation, feel free to read the `contributing - guidelines `__ and open a PR. - -Persistence and high-availability ---------------------------------- - -Currently, due to the way kubernetes and cassandra -`interact `__, -cassandra cannot reliably be installed on kubernetes. Some people have -tried, e.g. `this -project `__ though at -the time of writing (Nov 2018), this does not yet work as advertised. We -recommend therefore to install cassandra, (possibly also elasticsearch -and redis) separately, i.e. outside of kubernetes (using 3 nodes each). - -For further higher-availability: - -- scale your kubernetes cluster to have separate etcd and master nodes - (3 nodes each) -- use 3 instead of 1 replica of each wire-server chart - -Security --------- - -For a production deployment, you should, as a minimum: - -- Ensure traffic between kubernetes nodes, etcd and databases are - confined to a private network -- Ensure kubernetes API is unreachable from the public internet (e.g. - put behind VPN/bastion host or restrict IP range) to prevent - `kubernetes - vulnerabilities `__ - from affecting you -- Ensure your operating systems get security updates automatically -- Restrict ssh access / harden sshd configuration -- Ensure no other pods with public access than the main ingress are - deployed on your cluster, since, in the current setup, pods have - access to etcd values (and thus any secrets stored there, including - secrets from other pods) -- Ensure developers encrypt any secrets.yaml files - -Additionally, you may wish to build, sign, and host your own docker -images to have increased confidence in those images. We haved "signed -container images" on our roadmap. - -Sign up with a phone number (Sending SMS) ------------------------------------------ - -**Provides**: - -- Registering accounts with a phone number - -**You need**: - -- a `Nexmo `__ account -- a `Twilio `__ account - -**How to configure**: - -See the ``brig`` chart for configuration. - -.. _3rd-party-proxying: - -3rd-party proxying ------------------- - -You need Giphy/Google/Spotify/Soundcloud API keys (if you want to -support previews by proxying these services) - -See the ``proxy`` chart for configuration. - -Routing traffic to other namespaces via nginz ---------------------------------------------- - -If you have some components running in namespaces different from nginz. For -instance, the billing service (``ibis``) could be deployed to a separate -namespace, say ``integrations``. But it still needs to get traffic via -``nginz``. When this is needed, the helm config can be adjusted like this: - -.. code:: yaml - - # in your wire-server/values.yaml overrides: - nginz: - nginx_conf: - upstream_namespace: - ibis: integrations - -Marking an installation as self-hosted --------------------------------------- - -In case your wire installation is self-hosted (on-premise, demo installs), it needs to be aware that it is through a configuration option. As of release chart 4.15.0, `"true"` is the default behavior, and nothing needs to be done. - -If that option is not set, team-settings will prompt users about "wire for free" and associated functions. - -With that option set, all payment related functionality is disabled. - -The option is `IS_SELF_HOSTED`, and you set it in your `values.yaml` file (originally a copy of `prod-values.example.yaml` found in `wire-server-deploy/values/wire-server/`). - -In case of a demo install, replace `prod` with `demo`. - -First set the option under the `team-settings` section, `envVars` sub-section: - -.. code:: yaml - - # NOTE: Only relevant if you want team-settings - team-settings: - envVars: - IS_SELF_HOSTED: "true" - -Second, also set the option under the `account-pages` section: - -.. code:: yaml - - # NOTE: Only relevant if you want account-pages - account-pages: - envVars: - IS_SELF_HOSTED: "true" - -.. _auth-cookie-config: - -Configuring authentication cookie throttling --------------------------------------------- - -Authentication cookies and the related throttling mechanism is described in the *Client API documentation*: -:ref:`login-cookies` - -The maximum number of cookies per account and type is defined by the brig option -``setUserCookieLimit``. Its default is ``32``. - -Throttling is configured by the brig option ``setUserCookieThrottle``. It is an -object that contains two fields: - -``stdDev`` - The minimal standard deviation of cookie creation timestamps in - Seconds. (Default: ``3000``, - `Wikipedia: Standard deviation `_) - -``retryAfter`` - Wait time in Seconds when ``stdDev`` is violated. (Default: ``86400``) - -The default values are fine for most use cases. (Generally, you don't have to -configure them for your installation.) - -Condensed example: - - -.. code:: yaml - - brig: - optSettings: - setUserCookieLimit: 32 - setUserCookieThrottle: - stdDev: 3000 - retryAfter: 86400 - - -Configuring searchability -------------------------- - -You can configure how search is limited or not based on user membership in a given team. - -There are two types of searches based on the direction of search: - -* **Inbound** searches mean that somebody is searching for you. Configuring the inbound search visibility means that you (or some admin) can configure whether others can find you or not. -* **Outbound** searches mean that you are searching for somebody. Configuring the outbound search visibility means that some admin can configure whether you can find other users or not. - -There are different types of matches: - -* **Exact handle** search means that the user is found only if the search query is exactly the user handle (e.g. searching for `mc` will find `@mc` but not `@mccaine`). This search returns zero or one results. -* **Full text** search means that the user is found if the search query contains some subset of the user display name and handle. (e.g. the query `mar` will find `Marco C`, `Omar`, `@amaro`) - -Searching users on the same backend -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Search visibility is controlled by three parameters on the backend: - -* A team outbound configuration flag, `TeamSearchVisibility` with possible values `SearchVisibilityStandard`, `SearchVisibilityNoNameOutsideTeam` - - * `SearchVisibilityStandard` means that the user can find other people outside of the team, if the searched-person inbound search allows it - * `SearchVisibilityNoNameOutsideTeam` means that the user can not find any user outside the team by full text search (but exact handle search still works) - -* A team inbound configuration flag, `SearchVisibilityInbound` with possible values `SearchableByOwnTeam`, `SearchableByAllTeams` - - * `SearchableByOwnTeam` means that the user can be found only by users in their own team. - * `SearchableByAllTeams` means that the user can be found by users in any/all teams. - -* A server configuration flag `searchSameTeamOnly` with possible values true, false. - - * ``Note``: For the same backend, this affects inbound and outbound searches (simply because all teams will be subject to this behavior) - * Setting this to `true` means that the all teams on that backend can only find users that belong to their team - -These flag are set on the backend and the clients do not need to be aware of them. - -The flags will influence the behavior of the search API endpoint; clients will only need to parse the results, that are already filtered for them by the backend. - -Table of possible outcomes -.......................... - -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Is search-er (`uA`) in team (tA)? | Is search-ed (`uB`) in a team? | Backend flag `searchSameTeamOnly` | Team `tA`'s flag `TeamSearchVisibility` | Team tB's flag `SearchVisibilityInbound` | Result of exact search for `uB` | Result of full-text search for `uB` | -+====================================+=================================+====================================+==========================================+===========================================+==================================+======================================+ -| **Search within the same team** | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | Yes, the same team `tA` | Irrelevant | Irrelevant | Irrelevant | Found | Found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| **Outbound search unrestricted** | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityStandard` | `SearchableByAllTeams` | Found | Found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityStandard` | `SearchableByOwnTeam` | Found | Not found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| **Outbound search restricted** | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | Yes, another team tB | true | Irrelevant | Irrelevant | Not found | Not found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | Yes, another team tB | false | `SearchVisibilityNoNameOutsideTeam` | Irrelevant | Found | Not found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ -| Yes, `tA` | No | false | `SearchVisibilityNoNameOutsideTeam` | There’s no team B | Found | Not found | -+------------------------------------+---------------------------------+------------------------------------+------------------------------------------+-------------------------------------------+----------------------------------+--------------------------------------+ - - -Changing the configuration on the server -........................................ - -To change the `searchSameTeamOnly` setting on the backend, edit the `values.yaml.gotmpl` file for the wire-server chart at this nested level of the configuration: - -.. code:: yaml - - brig: - # ... - config: - # ... - optSettings: - # ... - setSearchSameTeamOnly: true - -If `setSearchSameTeamOnly` is set to `true` then `TeamSearchVisibility` is forced be in the `SearchVisibilityNoNameOutsideTeam` setting for all teams. - -Changing the default configuration for all teams -................................................ - -If `setSearchSameTeamOnly` is set to `false` (or missing from the configuration) then the default value `TeamSearchVisibility` can be configured at this level of the configuration of the `value.yaml.gotmpl` file of the wire-server chart: - - -.. code:: yaml - - galley: - #... - config: - #... - settings: - #... - featureFlags: - #... - teamSearchVisibility: enabled-by-default - -This default value applies to all teams for which no explicit configuration of the `TeamSearchVisibility` has been set. - - -Searching users on another (federated) backend -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For federated search the table above does not apply, see following table. - -.. note:: - - Incoming federated searches (i.e. searches from one backend to another) are considered always as being performed from a team user, even if they are performed from a personal user. - - This is because the incoming search request does not carry the information whether the user performing the search was in a team or not. - - So we have to make one assumption, and we assume that they were in a team. - -Allowing search is done at the backend configuration level by the sysadmin: - -* Outbound search restrictions (`searchSameTeamOnly`, `TeamSearchVisibility`) do not apply to federated searches -* A configuration setting `FederatedUserSearchPolicy` per federating domain with these possible values: - - * `no_search` The federating backend is not allowed to search any users (either by exact handle or full-text). - * `exact_handle_search` The federating backend may only search by exact handle - * `full_search` The federating backend may search users by full text search on display name and handle. The search search results are additionally affected by `SearchVisibilityInbound` setting of each team on the backend. -* The `SearchVisibilityInbound` setting applies. Since the default value for teams is `SearchableByOwnTeam` this means that for a team to be full-text searchable by users on a federating backend both - - * `FederatedUserSearchPolicy` needs to be set to to full_search for the federating backend - * Any team that wants to be full-text searchable needs to be set to `SearchableByAllTeams` - -The configuration value `FederatedUserSearchPolicy` is per federated domain, e.g. in the values of the wire-server chart: - -.. code:: yaml - - brig: - config: - optSettings: - setFederationDomainConfigs: - - domain: a.example.com - search_policy: no_search - - domain: a.example.com - search_policy: full_search - -Table of possible outcomes -.......................... - -In the following table, user `uA` on backend A is searching for user `uB` on team `tB` on backend B. - -Any of the flags set for searching users on the same backend are ignored. - -It’s worth nothing that if two users are on two separate backend, they are also guaranteed to be on two separate teams, as teams can not spread across backends. - -+-------------------------+---------------------------------------------+---------------------------------------------+----------------------------------+--------------------------------------+ -| Who is searching | Backend B flag `FederatedUserSearchPolicy` | Team `tB`'s flag `SearchVisibilityInbound` | Result of exact search for `uB` | Result of full-text search for `uB` | -+=========================+=============================================+=============================================+==================================+======================================+ -| user `uA` on backend A | `no_search` | Irrelevant | Not found | Not found | -+-------------------------+---------------------------------------------+---------------------------------------------+----------------------------------+--------------------------------------+ -| user `uA` on backend A | `exact_handle_search` | Irrelevant | Found | Not found | -+-------------------------+---------------------------------------------+---------------------------------------------+----------------------------------+--------------------------------------+ -| user `uA` on backend A | `full_search` | SearchableByOwnTeam | Found | Not found | -+-------------------------+---------------------------------------------+---------------------------------------------+----------------------------------+--------------------------------------+ -| user `uA` on backend A | `full_search` | SearchableByAllTeams | Found | Found | -+-------------------------+---------------------------------------------+---------------------------------------------+----------------------------------+--------------------------------------+ - -Changing the settings for a given team -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you need to change searchabilility for a specific team (rather than the entire backend, as above), you need to make specific calls to the API. - -Team searchVisibility -..................... - -The team flag `searchVisibility` affects the outbound search of user searches. - -If it is set to `no-name-outside-team` for a team then all users of that team will no longer be able to find users that are not part of their team when searching. - -This also includes finding other users by by providing their exact handle. By default it is set to `standard`, which doesn't put any additional restrictions to outbound searches. - -The setting can be changed via endpoint (for more details on how to make the API calls with `curl`, read further): - -.. code:: - - GET /teams/{tid}/search-visibility - -- Shows the current TeamSearchVisibility value for the given team - - PUT /teams/{tid}/search-visibility - -- Set specific search visibility for the team - - pull-down-menu "body": - "standard" - "no-name-outside-team" - -The team feature flag `teamSearchVisibility` determines whether it is allowed to change the `searchVisibility` setting or not. - -The default is `disabled-by-default`. - -.. note:: - - Whenever this feature setting is disabled the `searchVisibility` will be reset to standard. - -The default setting that applies to all teams on the instance can be defined at configuration - -.. code:: yaml - - settings: - featureFlags: - teamSearchVisibility: disabled-by-default # or enabled-by-default - -TeamFeature searchVisibilityInbound -................................... - -The team feature flag `searchVisibilityInbound` affects if the team's users are searchable by users from other teams. - -The default setting is `searchable-by-own-team` which hides users from search results by users from other teams. - -If it is set to `searchable-by-all-teams` then users of this team may be included in the results of search queries by other users. - -.. note:: - - The configuration of this flag does not affect search results when the search query matches the handle exactly. - - If the handle is provdided then any user on the instance can find users. - -This team feature flag can only by toggled by site-administrators with direct access to the galley instance (for more details on how to make the API calls with `curl`, read further): - -.. code:: - - PUT /i/teams/{tid}/features/search-visibility-inbound - -With JSON body: - -.. code:: json - - {"status": "enabled"} - -or - -.. code:: json - - {"status": "disabled"} - -Where `enabled` is equivalent to `searchable-by-all-teams` and `disabled` is equivalent to `searchable-by-own-team`. - -The default setting that applies to all teams on the instance can be defined at configuration. - -.. code:: yaml - - searchVisibilityInbound: - defaults: - status: enabled # OR disabled - -Individual teams can overwrite the default setting with API calls as per above. - -Making the API calls -.................... - -To make API calls to set an explicit configuration for` TeamSearchVisibilityInbound` per team, you first need to know the Team ID, which can be found in the team settings app. - -It is an `UUID` which has format like this `dcbedf9a-af2a-4f43-9fd5-525953a919e1`. - -In the following we will be using this Team ID as an example, please replace it with your own team id. - -Next find the name of a `galley` pod by looking at the output of running this command: - -.. code:: sh - - kubectl -n wire get pods - -The output will look something like this: - -.. code:: - - ... - galley-5f4787fdc7-9l64n ... - galley-migrate-data-lzz5j ... - ... - -Select any of the galley pods, for example we will use `galley-5f4787fdc7-9l64n`. - -Next, set up a port-forwarding from your local machine's port `9000` to the galley's port `8080` by running: - -.. code:: sh - - kubectl port-forward -n wire galley-5f4787fdc7-9l64n 9000:8080 - -Keep this command running until the end of these instuctions. - -Please run the following commands in a seperate terminal while keeping the terminal which establishes the port-forwarding open. - -To see team's current setting run: - -.. code:: sh - - curl -XGET http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound - - # {"lockStatus":"unlocked","status":"disabled"} - -Where `disabled` corresponds to `SearchableByOwnTeam` and enabled corresponds to `SearchableByAllTeams`. - -To change the `TeamSearchVisibilityInbound` to `SearchableByAllTeams` for the team run: - -.. code:: sh - - curl -XPUT -H 'Content-Type: application/json' -d "{\"status\": \"enabled\"}" http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound - -To change the TeamSearchVisibilityInbound to SearchableByOwnTeam for the team run: - -.. code:: sh - - curl -XPUT -H 'Content-Type: application/json' -d "{\"status\": \"disabled\"}" http://localhost:9000/i/teams/dcbedf9a-af2a-4f43-9fd5-525953a919e1/features/searchVisibilityInbound - - - -Configuring classified domains ------------------------------- - -As a backend administrator, if you want to control which other backends (identified by their domain) are "classified", - -change the following `galley` configuration in the `value.yaml.gotmpl` file of the wire-server chart: - -.. code:: yaml - - galley: - replicaCount: 1 - config: - ... - featureFlags: - ... - classifiedDomains: - status: enabled - config: - domains: ["domain-that-is-classified.link"] - ... - -This is not only a `backend` configuration, but also a `team` configuration/feature. - -This means that different combinations of configurations will have different results. - -Here is a table to navigate the possible configurations: - -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Backend Config enabled/disabled | Backend Config Domains | Team Config enabled/disabled | Team Config Domains | User's view | -+==================================+=============================================+===============================+========================+=================================+ -| Enabled | [domain1.example.com] | Not configured | Not configured | Enabled, [domain1.example.com] | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Enabled | [domain1.example.com][domain1.example.com] | Enabled | Not configured | Enabled, [domain1.example.com] | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Enabled | [domain1.example.com] | Enabled | [domain2.example.com] | Enabled, Undefined | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Enabled | [domain1.example.com] | Disabled | Anything | Undefined | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Disabled | Anything | Not configured | Not configured | Disabled, no domains | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ -| Disabled | Anything | Enabled | [domain2.example.com] | Undefined | -+----------------------------------+---------------------------------------------+-------------------------------+------------------------+---------------------------------+ - -The table assumes the following: - -* When backend level config says that this feature is enabled, it is illegal to not specify domains at the backend level. -* When backend level config says that this feature is disabled, the list of domains is ignored. -* When team level feature is disabled, the accompanying domains are ignored. - -S3 Addressing Style -------------------- - -S3 can either by addressed in path style, i.e. -`https:////`, or vhost style, i.e. -`https://./`. AWS's S3 offering has deprecated -path style addressing for S3 and completely disabled it for buckets created -after 30 Sep 2020: -https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/ - -However other object storage providers (specially self-deployed ones like MinIO) -may not support vhost style addressing yet (or ever?). Users of such buckets -should configure this option to "path": - -.. code:: yaml - - cargohold: - aws: - s3AddressingStyle: path - -Installations using S3 service provided by AWS, should use "auto", this option -will ensure that vhost style is only used when it is possible to construct a -valid hostname from the bucket name and the bucket name doesn't contain a '.'. -Having a '.' in the bucket name causes TLS validation to fail, hence it is not -used by default: - -.. code:: yaml - - cargohold: - aws: - s3AddressingStyle: auto - - -Using "virtual" as an option is only useful in situations where vhost style -addressing must be used even if it is not possible to construct a valid hostname -from the bucket name or the S3 service provider can ensure correct certificate -is issued for bucket which contain one or more '.'s in the name: - -.. code:: yaml - - cargohold: - aws: - s3AddressingStyle: virtual - -When this option is unspecified, wire-server defaults to path style addressing -to ensure smooth transition for older deployments. diff --git a/docs/src/how-to/install/dependencies.md b/docs/src/how-to/install/dependencies.md new file mode 100644 index 0000000000..43ad7f7d90 --- /dev/null +++ b/docs/src/how-to/install/dependencies.md @@ -0,0 +1,69 @@ +(dependencies)= + +# Dependencies on operator's machine + +In order to operate a wire-server installation, you'll need a bunch of software +like Ansible, `kubectl` and Helm. + +Together with a matching checkout of the wire-server-deploy repository, +containing the Ansible Roles and Playbooks, you should be good to go. + +Checkout the repository, including its submodules: + +``` +git clone --branch master https://github.com/wireapp/wire-server-deploy.git +cd wire-server-deploy +git submodule update --init --recursive +``` + +We provide a container containing all needed tools for setting up and +interacting with a wire-server cluster. + +Ensure you have Docker >= 20.10.14 installed, as the glibc version used is +incompatible with older container runtimes. + +Your Distro might ship an older version, so best see [how to install docker](https://docker.com). + +To bring the tools in scope, we run the container, and mount the local `wire-server-deploy` +checkout into it. + +Replace the container image tag with the commit id your `wire-server-deploy` +checkout is pointing to. + +``` +WSD_COMMIT_ID=cdc1c84c1a10a4f5f1b77b51ee5655d0da7f9518 # set me +WSD_CONTAINER=quay.io/wire/wire-server-deploy:$WSD_COMMIT_ID + +sudo docker run -it --network=host \ + -v ${SSH_AUTH_SOCK:-nonexistent}:/ssh-agent \ + -v $HOME/.ssh:/root/.ssh \ + -v $PWD:/wire-server-deploy \ + -e SSH_AUTH_SOCK=/ssh-agent \ + $WSD_CONTAINER bash + +# Inside the container +bash-4.4# ansible --version +ansible 2.9.12 +``` + +Once you're in there, you can move on to {ref}`installing kubernetes `. + +## (Alternative) Installing dependencies using Direnv and Nix + +```{warning} +This is an alternative approach to the above "wrapping container" one, which you should only use if you can't get above setup to work. +``` + +1. [Install Nix](https://nixos.org/download.html) +2. [Install Direnv](https://direnv.net/docs/installation.html) +3. [Optionally install the Wire cachix cache to download binaries](https://app.cachix.org/cache/wire-server) + +Now, enabling `direnv` should install all the dependencies and add them to your `PATH`. Every time you `cd` into +the `wire-server-deploy` directory, the right dependencies will be available. + +``` +direnv allow + +ansible --version +ansible 2.9.12 +``` diff --git a/docs/src/how-to/install/dependencies.rst b/docs/src/how-to/install/dependencies.rst deleted file mode 100644 index 4c50f38d25..0000000000 --- a/docs/src/how-to/install/dependencies.rst +++ /dev/null @@ -1,74 +0,0 @@ -.. _dependencies: - -Dependencies on operator's machine --------------------------------------------------------------------- - -In order to operate a wire-server installation, you'll need a bunch of software -like Ansible, ``kubectl`` and Helm. - -Together with a matching checkout of the wire-server-deploy repository, -containing the Ansible Roles and Playbooks, you should be good to go. - -Checkout the repository, including its submodules: - -:: - - git clone --branch master https://github.com/wireapp/wire-server-deploy.git - cd wire-server-deploy - git submodule update --init --recursive - - -We provide a container containing all needed tools for setting up and -interacting with a wire-server cluster. - -Ensure you have Docker >= 20.10.14 installed, as the glibc version used is -incompatible with older container runtimes. - -Your Distro might ship an older version, so best see `how to install docker -`__. - -To bring the tools in scope, we run the container, and mount the local ``wire-server-deploy`` -checkout into it. - -Replace the container image tag with the commit id your ``wire-server-deploy`` -checkout is pointing to. - -:: - - WSD_COMMIT_ID=cdc1c84c1a10a4f5f1b77b51ee5655d0da7f9518 # set me - WSD_CONTAINER=quay.io/wire/wire-server-deploy:$WSD_COMMIT_ID - - sudo docker run -it --network=host \ - -v ${SSH_AUTH_SOCK:-nonexistent}:/ssh-agent \ - -v $HOME/.ssh:/root/.ssh \ - -v $PWD:/wire-server-deploy \ - -e SSH_AUTH_SOCK=/ssh-agent \ - $WSD_CONTAINER bash - - # Inside the container - bash-4.4# ansible --version - ansible 2.9.12 - -Once you're in there, you can move on to `installing kubernetes `__ - - -(Alternative) Installing dependencies using Direnv and Nix -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. warning:: - - This is an alternative approach to the above "wrapping container" one, which you should only use if you can't get above setup to work. - -1. `Install Nix `__ -2. `Install Direnv `__ -3. `Optionally install the Wire cachix cache to download binaries `__ - -Now, enabling ``direnv`` should install all the dependencies and add them to your ``PATH``. Every time you ``cd`` into -the ``wire-server-deploy`` directory, the right dependencies will be available. - -:: - - direnv allow - - ansible --version - ansible 2.9.12 diff --git a/docs/src/how-to/install/helm-prod.md b/docs/src/how-to/install/helm-prod.md new file mode 100644 index 0000000000..29045f1818 --- /dev/null +++ b/docs/src/how-to/install/helm-prod.md @@ -0,0 +1,208 @@ +(helm-prod)= + +# Installing wire-server (production) components using Helm + +```{note} +Code in this repository should be considered *beta*. As of 2020, we do not (yet) +run our production infrastructure on Kubernetes (but plan to do so soon). +``` + +## Introduction + +The following will install a version of all the wire-server components. These instructions are for reference, and may not set up what you would consider a production environment, due to the fact that there are varying definitions of 'production ready'. These instructions will cover what we consider to be a useful overlap of our users' production needs. They do not cover load balancing/distributing, using multiple datacenters, federating wire, or other forms of intercontinental/interplanetary distribution of the wire service infrastructure. If you deviate from these directions and need to contact us for support, please provide the deviations you made to fit your production environment along with your support request. + +Some of the instructions here will present you with two options: No AWS, and with AWS. The 'No AWS' instructions will not require any AWS infrastructure, but may have a reduced feature set. The 'with AWS' instructions will assume you have completed the setup procedures in {ref}`aws-prod`. + +### What will be installed? + +- wire-server (API) + : - user accounts, authentication, conversations + - assets handling (images, files, ...) + - notifications over websocket +- wire-webapp, a fully functioning web client (like `https://app.wire.com/`) +- wire-account-pages, user account management (a few pages relating to e.g. password reset procedures) + +### What will not be installed? + +- team-settings page +- SSO Capabilities + +Additionally, if you opt to do the 'No AWS' route, you will not get: + +- notifications over native push notifications via [FCM](https://firebase.google.com/docs/cloud-messaging/)/[APNS](https://developer.apple.com/notifications/) + +## Prerequisites + +You need to have access to a Kubernetes cluster running a Kubernetes version , and the `helm` local binary on your PATH. +Your Kubernetes cluster needs to have internal DNS services, so that wire-server can find it's databases. +You need to have docker on the machine you are using to perform this installation with, or a secure data path to a machine that runs docker. You will be using docker to generate security credentials for your wire installation. + +- If you want calling services, you need to have + + - FIXME + +- If you don't have a Kubernetes cluster, you have two options: + + - You can get access to a managed Kubernetes cluster with the cloud provider of your choice. + - You can install one if you have ssh access to a set of sufficiently large virtual machines, see {ref}`ansible-kubernetes` + +- If you don't have `helm` yet, see [Installing helm](https://helm.sh/docs/using_helm/#installing-helm). If you followed the instructions in {ref}`dependencies` should have helm installed already. + +Type `helm version`, you should, if everything is configured correctly, see a result similar this: + +``` +version.BuildInfo{Version:"v3.1.1", GitCommit:"afe70585407b420d0097d07b21c47dc511525ac8", GitTreeState:"clean", GoVersion:"go1.13.8"} +``` + +In case `kubectl version` shows both Client and Server versions, but `helm version` does not show a Server version, you may need to run `helm init`. The exact version matters less as long as both Client and Server versions match (or are very close). + +## Preparing to install charts from the internet with Helm + +If your environment is online, you need to add the remote wire Helm repository, to download wire charts. + +To enable the wire charts helm repository: + +```shell +helm repo add wire https://s3-eu-west-1.amazonaws.com/public.wire.com/charts +``` + +(You can see available helm charts by running `helm search repo wire/`. To see +new versions as time passes, you may need to run `helm repo update`) + +Great! Now you can start installing. + +There is a shell script for doing a version of the following procedure with Helm 22. For reference, examine [prod-setup.sh](https://github.com/wireapp/wire-server-deploy/blob/develop/bin/prod-setup.sh). + +## Watching changes as they happen + +Open a terminal and run: + +```shell +kubectl get pods -w +``` + +This will block your terminal and show some things happening as you proceed through this guide. Keep this terminal open and open a second terminal. + +## General installation notes + +```{note} +All helm and kubectl commands below can also take an extra `--namespace ` if you don't want to install into the default Kubernetes namespace. +``` + +## How to install charts that provide access to external databases + +Before you can deploy the helm charts that tell wire where external services +are, you need the 'values' and 'secrets' files for those charts to be +configured. Values and secrets YAML files provide helm charts with the settings +that are installed in Kubernetes. + +Assuming you have followed the procedures in the previous document, the values +and secrets files for cassandra, elasticsearch, and minio (if you are using it) +will have been filled in automatically. If not, examine the +`prod-values.example.yaml` files for each of these services in +values/\/, copy them to `values.yaml`, and then edit them. + +Once the values and secrets files for your databases have been configured, you +have to write a `values/databases-ephemeral/values.yaml` file to tell +databases-ephemeral what external database services you are using, and what +services you want databases-ephemeral to configure. We recommend you use the +'redis' component from this only, as the contents of redis are in fact +ephemeral. Look at the `values/databases-ephemeral/prod-values.example.yaml` +file + +Once you have values and secrets for your environment, open a terminal and run: + +```shell +helm upgrade --install cassandra-external wire/cassandra-external -f values/cassandra-external/values.yaml --wait +helm upgrade --install elasticsearch-external wire/elasticsearch-external -f values/elasticsearch-external/values.yaml --wait +helm upgrade --install databases-ephemeral wire/databases-ephemeral -f values/databases-ephemeral/values.yaml --wait +``` + +If you are using minio instead of AWS S3, you should also run: + +```shell +helm upgrade --install minio-external wire/minio-external -f values/minio-external/values.yaml --wait +``` + +## How to install fake AWS services for SNS / SQS + +AWS SNS is required to send notifications to clients. SQS is used to get notified of any devices that have discontinued using Wire (e.g. if you uninstall the app, the push notification token is removed, and the wire-server will get feedback for that using SQS). + +Note: *for using real SQS for real native push notifications instead, see also :ref:\`pushsns\`.* + +If you use the fake-aws version, clients will use the websocket method to receive notifications, which keeps connections to the servers open, draining battery. + +Open a terminal and run: + +```shell +cp values/fake-aws/prod-values.example.yaml values/fake-aws/values.yaml +helm upgrade --install fake-aws wire/fake-aws -f values/fake-aws/values.yaml --wait +``` + +You should see some pods being created in your first terminal as the above command completes. + +## Preparing to install wire-server + +As part of configuring wire-server, we need to change some values, and provide some secrets. We're going to copy the files for this to a new folder, so that you always have the originals for reference. + +```{note} +This part of the process makes use of overrides for helm charts. You may wish to read {ref}`understand-helm-overrides` first. +``` + +```shell +mkdir -p my-wire-server +cp values/wire-server/prod-secrets.example.yaml my-wire-server/secrets.yaml +cp values/wire-server/prod-values.example.yaml my-wire-server/values.yaml +``` + +## How to configure real SMTP (email) services + +In order for users to interact with their wire account, they need to receive mail from your wire server. + +If you are using a mail server, you will need to provide your authentication credentials before setting up wire. + +- Add your SMTP username in my-wire-server/values.yaml under `brig.config.smtp.username`. You may need to add an entry for username. +- Add your SMTP password is my-wire-server/secrets.yaml under `brig.secrets.smtpPassword`. + +## How to install fake SMTP (email) services + +If you are not making use of mail services, and are adding your users via some other means, you can use demo-smtp, as a placeholder. + +```shell +cp values/demo-smtp/prod-values.example.yaml values/demo-smtp/values.yaml +helm upgrade --install smtp wire/demo-smtp -f values/demo-smtp/values.yaml +``` + +You should see some pods being created in your first terminal as the above command completes. + +## How to install wire-server itself + +Open `my-wire-server/values.yaml` and replace `example.com` and other domains and subdomains with domains of your choosing. Look for the `# change this` comments. You can try using `sed -i 's/example.com//g' values.yaml`. + +1. If you are not using team settings, comment out `teamSettings` under `brig.config.externalURLs`. + +Generate some secrets: + +```shell +openssl rand -base64 64 | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 42 > my-wire-server/restund.txt +apt install docker-ce +sudo docker run --rm quay.io/wire/alpine-intermediate /dist/zauth -m gen-keypair -i 1 > my-wire-server/zauth.txt +``` + +1. Add the generated secret from my-wire-server/restund.txt to my-wire-serwer/secrets.yaml under `brig.secrets.turn.secret` +2. add **both** the public and private parts from zauth.txt to secrets.yaml under `brig.secrets.zAuth` +3. Add the public key from zauth.txt to secrets.yaml under `nginz.secrets.zAuth.publicKeys` + +Great, now try the installation: + +```shell +helm upgrade --install wire-server wire/wire-server -f my-wire-server/values.yaml -f my-wire-server/secrets.yaml --wait +``` + +(helmdns)= + +## DNS records + +```{eval-rst} +.. include:: includes/helm_dns-ingress-troubleshooting.inc.rst +``` diff --git a/docs/src/how-to/install/helm-prod.rst b/docs/src/how-to/install/helm-prod.rst deleted file mode 100644 index fb9b81841d..0000000000 --- a/docs/src/how-to/install/helm-prod.rst +++ /dev/null @@ -1,225 +0,0 @@ -.. _helm_prod: - -Installing wire-server (production) components using Helm -========================================================= - -.. note:: - - Code in this repository should be considered *beta*. As of 2020, we do not (yet) - run our production infrastructure on Kubernetes (but plan to do so soon). - -Introduction ------------- - -The following will install a version of all the wire-server components. These instructions are for reference, and may not set up what you would consider a production environment, due to the fact that there are varying definitions of 'production ready'. These instructions will cover what we consider to be a useful overlap of our users' production needs. They do not cover load balancing/distributing, using multiple datacenters, federating wire, or other forms of intercontinental/interplanetary distribution of the wire service infrastructure. If you deviate from these directions and need to contact us for support, please provide the deviations you made to fit your production environment along with your support request. - -Some of the instructions here will present you with two options: No AWS, and with AWS. The 'No AWS' instructions will not require any AWS infrastructure, but may have a reduced feature set. The 'with AWS' instructions will assume you have completed the setup procedures in :ref:`aws_prod`. - -What will be installed? -^^^^^^^^^^^^^^^^^^^^^^^ - -- wire-server (API) - - user accounts, authentication, conversations - - assets handling (images, files, ...) - - notifications over websocket -- wire-webapp, a fully functioning web client (like ``https://app.wire.com/``) -- wire-account-pages, user account management (a few pages relating to e.g. password reset procedures) - -What will not be installed? -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- team-settings page -- SSO Capabilities - -Additionally, if you opt to do the 'No AWS' route, you will not get: - -- notifications over native push notifications via `FCM `__/`APNS `__ - -Prerequisites -------------- - -You need to have access to a Kubernetes cluster running a Kubernetes version , and the ``helm`` local binary on your PATH. -Your Kubernetes cluster needs to have internal DNS services, so that wire-server can find it's databases. -You need to have docker on the machine you are using to perform this installation with, or a secure data path to a machine that runs docker. You will be using docker to generate security credentials for your wire installation. - -* If you want calling services, you need to have - - * FIXME - -* If you don't have a Kubernetes cluster, you have two options: - - * You can get access to a managed Kubernetes cluster with the cloud provider of your choice. - * You can install one if you have ssh access to a set of sufficiently large virtual machines, see :ref:`ansible-kubernetes` - -* If you don't have ``helm`` yet, see `Installing helm `__. If you followed the instructions in :ref:`dependencies` should have helm installed already. - - -Type ``helm version``, you should, if everything is configured correctly, see a result similar this: - -:: - - version.BuildInfo{Version:"v3.1.1", GitCommit:"afe70585407b420d0097d07b21c47dc511525ac8", GitTreeState:"clean", GoVersion:"go1.13.8"} - -In case ``kubectl version`` shows both Client and Server versions, but ``helm version`` does not show a Server version, you may need to run ``helm init``. The exact version matters less as long as both Client and Server versions match (or are very close). - - -Preparing to install charts from the internet with Helm -------------------------------------------------------- -If your environment is online, you need to add the remote wire Helm repository, to download wire charts. - -To enable the wire charts helm repository: - -.. code:: shell - - helm repo add wire https://s3-eu-west-1.amazonaws.com/public.wire.com/charts - -(You can see available helm charts by running ``helm search repo wire/``. To see -new versions as time passes, you may need to run ``helm repo update``) - -Great! Now you can start installing. - -There is a shell script for doing a version of the following procedure with Helm 22. For reference, examine `prod-setup.sh `__. - -Watching changes as they happen -------------------------------- - -Open a terminal and run: - -.. code:: shell - - kubectl get pods -w - -This will block your terminal and show some things happening as you proceed through this guide. Keep this terminal open and open a second terminal. - -General installation notes --------------------------- - -.. note:: - - All helm and kubectl commands below can also take an extra ``--namespace `` if you don't want to install into the default Kubernetes namespace. - -How to install charts that provide access to external databases ---------------------------------------------------------------- - -Before you can deploy the helm charts that tell wire where external services -are, you need the 'values' and 'secrets' files for those charts to be -configured. Values and secrets YAML files provide helm charts with the settings -that are installed in Kubernetes. - -Assuming you have followed the procedures in the previous document, the values -and secrets files for cassandra, elasticsearch, and minio (if you are using it) -will have been filled in automatically. If not, examine the -``prod-values.example.yaml`` files for each of these services in -values//, copy them to ``values.yaml``, and then edit them. - -Once the values and secrets files for your databases have been configured, you -have to write a ``values/databases-ephemeral/values.yaml`` file to tell -databases-ephemeral what external database services you are using, and what -services you want databases-ephemeral to configure. We recommend you use the -'redis' component from this only, as the contents of redis are in fact -ephemeral. Look at the ``values/databases-ephemeral/prod-values.example.yaml`` -file - -Once you have values and secrets for your environment, open a terminal and run: - -.. code:: shell - - helm upgrade --install cassandra-external wire/cassandra-external -f values/cassandra-external/values.yaml --wait - helm upgrade --install elasticsearch-external wire/elasticsearch-external -f values/elasticsearch-external/values.yaml --wait - helm upgrade --install databases-ephemeral wire/databases-ephemeral -f values/databases-ephemeral/values.yaml --wait - -If you are using minio instead of AWS S3, you should also run: - -.. code:: shell - - helm upgrade --install minio-external wire/minio-external -f values/minio-external/values.yaml --wait - -How to install fake AWS services for SNS / SQS ----------------------------------------------- - -AWS SNS is required to send notifications to clients. SQS is used to get notified of any devices that have discontinued using Wire (e.g. if you uninstall the app, the push notification token is removed, and the wire-server will get feedback for that using SQS). - -Note: *for using real SQS for real native push notifications instead, see also :ref:`pushsns`.* - -If you use the fake-aws version, clients will use the websocket method to receive notifications, which keeps connections to the servers open, draining battery. - -Open a terminal and run: - -.. code:: shell - - cp values/fake-aws/prod-values.example.yaml values/fake-aws/values.yaml - helm upgrade --install fake-aws wire/fake-aws -f values/fake-aws/values.yaml --wait - -You should see some pods being created in your first terminal as the above command completes. - - -Preparing to install wire-server --------------------------------- -As part of configuring wire-server, we need to change some values, and provide some secrets. We're going to copy the files for this to a new folder, so that you always have the originals for reference. - -.. note:: - - This part of the process makes use of overrides for helm charts. You may wish to read :ref:`understand-helm-overrides` first. - - -.. code:: shell - - mkdir -p my-wire-server - cp values/wire-server/prod-secrets.example.yaml my-wire-server/secrets.yaml - cp values/wire-server/prod-values.example.yaml my-wire-server/values.yaml - - -How to configure real SMTP (email) services -------------------------------------------- -In order for users to interact with their wire account, they need to receive mail from your wire server. - -If you are using a mail server, you will need to provide your authentication credentials before setting up wire. - -- Add your SMTP username in my-wire-server/values.yaml under ``brig.config.smtp.username``. You may need to add an entry for username. -- Add your SMTP password is my-wire-server/secrets.yaml under ``brig.secrets.smtpPassword``. - - -How to install fake SMTP (email) services ------------------------------------------ -If you are not making use of mail services, and are adding your users via some other means, you can use demo-smtp, as a placeholder. - -.. code:: shell - - cp values/demo-smtp/prod-values.example.yaml values/demo-smtp/values.yaml - helm upgrade --install smtp wire/demo-smtp -f values/demo-smtp/values.yaml - - -You should see some pods being created in your first terminal as the above command completes. - -How to install wire-server itself ---------------------------------- - -Open ``my-wire-server/values.yaml`` and replace ``example.com`` and other domains and subdomains with domains of your choosing. Look for the ``# change this`` comments. You can try using ``sed -i 's/example.com//g' values.yaml``. - -1. If you are not using team settings, comment out ``teamSettings`` under ``brig.config.externalURLs``. - - -Generate some secrets: - -.. code:: shell - - openssl rand -base64 64 | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 42 > my-wire-server/restund.txt - apt install docker-ce - sudo docker run --rm quay.io/wire/alpine-intermediate /dist/zauth -m gen-keypair -i 1 > my-wire-server/zauth.txt - -1. Add the generated secret from my-wire-server/restund.txt to my-wire-serwer/secrets.yaml under ``brig.secrets.turn.secret`` -2. add **both** the public and private parts from zauth.txt to secrets.yaml under ``brig.secrets.zAuth`` -3. Add the public key from zauth.txt to secrets.yaml under ``nginz.secrets.zAuth.publicKeys`` - -Great, now try the installation: - -.. code:: shell - - helm upgrade --install wire-server wire/wire-server -f my-wire-server/values.yaml -f my-wire-server/secrets.yaml --wait - -.. _helmdns: - -DNS records ------------ - -.. include:: includes/helm_dns-ingress-troubleshooting.inc.rst diff --git a/docs/src/how-to/install/helm.md b/docs/src/how-to/install/helm.md new file mode 100644 index 0000000000..75ce93eda2 --- /dev/null +++ b/docs/src/how-to/install/helm.md @@ -0,0 +1,145 @@ +(helm)= + +# Installing wire-server (demo) components using helm + +## Introduction + +The following will install a demo version of all the wire-server components including the databases. This setup is not recommended in production but will get you started. + +Demo version means + +- easy setup - only one single machine with kubernetes is needed (make sure you have at least 4 CPU cores and 8 GB of memory available) +- no data persistence (everything stored in memory, will be lost) + +### What will be installed? + +- wire-server (API) + \- user accounts, authentication, conversations + \- assets handling (images, files, ...) + \- notifications over websocket +- wire-webapp, a fully functioning web client (like `https://app.wire.com`) +- wire-account-pages, user account management (a few pages relating to e.g. password reset) + +### What will not be installed? + +- notifications over native push notifications via [FCM](https://firebase.google.com/docs/cloud-messaging/)/[APNS](https://developer.apple.com/notifications/) +- audio/video calling servers using {ref}`understand-restund`) +- team-settings page + +## Prerequisites + +You need to have access to a kubernetes cluster, and the `helm` local binary on your PATH. + +- If you don't have a kubernetes cluster, you have two options: + + - You can get access to a managed kubernetes cluster with the cloud provider of your choice. + - You can install one if you have ssh access to a virtual machine, see {ref}`ansible-kubernetes` + +- If you don't have `helm` yet, see [Installing helm](https://helm.sh/docs/using_helm/#installing-helm). + +Type `helm version`, you should, if everything is configured correctly, see a result like this: + +``` +version.BuildInfo{Version:"v3.1.1", GitCommit:"afe70585407b420d0097d07b21c47dc511525ac8", GitTreeState:"clean", GoVersion:"go1.13.8"} +``` + +In case `kubectl version` shows both Client and Server versions, but `helm version` does not show a Server version, you may need to run `helm init`. The exact version (assuming `v2.X.X` - at the time of writing v3 is not yet supported) matters less as long as both Client and Server versions match (or are very close). + +## How to start installing charts from wire + +Enable the wire charts helm repository: + +```shell +helm repo add wire https://s3-eu-west-1.amazonaws.com/public.wire.com/charts +``` + +(You can see available helm charts by running `helm search repo wire/`. To see +new versions as time passes, you may need to run `helm repo update`) + +Great! Now you can start installing. + +```{note} +all commands below can also take an extra `--namespace ` if you don't want to install into the default kubernetes namespace. +``` + +## Watching changes as they happen + +Open a terminal and run + +```shell +kubectl get pods -w +``` + +This will block your terminal and show some things happening as you proceed through this guide. Keep this terminal open and open a second terminal. + +## How to install in-memory databases and external components + +In your second terminal, first install databases: + +```shell +helm upgrade --install databases-ephemeral wire/databases-ephemeral --wait +``` + +You should see some pods being created in your first terminal as the above command completes. + +You can do the following two steps (mock aws services and demo smtp +server) in parallel with the above in two more terminals, or +sequentially after database-ephemeral installation has succeeded. + +```shell +helm upgrade --install fake-aws wire/fake-aws --wait +helm upgrade --install smtp wire/demo-smtp --wait +``` + +## How to install wire-server itself + +```{note} +The following makes use of overrides for helm charts. You may wish to read {ref}`understand-helm-overrides` first. +``` + +Change back to the wire-server-deploy directory. Copy example demo values and secrets: + +```shell +mkdir -p wire-server && cd wire-server +cp ../values/wire-server/demo-secrets.example.yaml secrets.yaml +cp ../values/wire-server/demo-values.example.yaml values.yaml +``` + +Or, if you are not in wire-server-deploy, download example demo values and secrets: + +```shell +mkdir -p wire-server && cd wire-server +curl -sSL https://raw.githubusercontent.com/wireapp/wire-server-deploy/master/values/wire-server/demo-secrets.example.yaml > secrets.yaml +curl -sSL https://raw.githubusercontent.com/wireapp/wire-server-deploy/master/values/wire-server/demo-values.example.yaml > values.yaml +``` + +Open `values.yaml` and replace `example.com` and other domains and subdomains with domains of your choosing. Look for the `# change this` comments. You can try using `sed -i 's/example.com//g' values.yaml`. + +Generate some secrets (if you are using the docker image from {ref}`ansible-kubernetes`, you should open a shell on the host system for this): + +```shell +openssl rand -base64 64 | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 42 > restund.txt +docker run --rm quay.io/wire/alpine-intermediate /dist/zauth -m gen-keypair -i 1 > zauth.txt +``` + +1. Add the generated secret from restund.txt to secrets.yaml under `brig.secrets.turn.secret` +2. add **both** the public and private parts from zauth.txt to secrets.yaml under `brig.secrets.zAuth` +3. Add the public key from zauth.txt **also** to secrets.yaml under `nginz.secrets.zAuth.publicKeys` + +You can do this with an editor, or using sed: + +```shell +sed -i 's/secret:$/secret: content_of_restund.txt_file/' secrets.yaml +sed -i 's/publicKeys: ""/publicKeys: "public_key_from_zauth.txt_file"/' secrets.yaml +sed -i 's/privateKeys: ""/privateKeys: "private_key_from_zauth.txt_file"/' secrets.yaml +``` + +Great, now try the installation: + +```shell +helm upgrade --install wire-server wire/wire-server -f values.yaml -f secrets.yaml --wait +``` + +```{eval-rst} +.. include:: includes/helm_dns-ingress-troubleshooting.inc.rst +``` diff --git a/docs/src/how-to/install/helm.rst b/docs/src/how-to/install/helm.rst deleted file mode 100644 index 695a4c95a3..0000000000 --- a/docs/src/how-to/install/helm.rst +++ /dev/null @@ -1,154 +0,0 @@ -.. _helm: - -Installing wire-server (demo) components using helm -====================================================== - -Introduction ------------------ - -The following will install a demo version of all the wire-server components including the databases. This setup is not recommended in production but will get you started. - -Demo version means - -* easy setup - only one single machine with kubernetes is needed (make sure you have at least 4 CPU cores and 8 GB of memory available) -* no data persistence (everything stored in memory, will be lost) - -What will be installed? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- wire-server (API) - - user accounts, authentication, conversations - - assets handling (images, files, ...) - - notifications over websocket - -- wire-webapp, a fully functioning web client (like ``https://app.wire.com``) -- wire-account-pages, user account management (a few pages relating to e.g. password reset) - -What will not be installed? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- notifications over native push notifications via `FCM `__/`APNS `__ -- audio/video calling servers using :ref:`understand-restund`) -- team-settings page - -Prerequisites --------------------------------- - -You need to have access to a kubernetes cluster, and the ``helm`` local binary on your PATH. - -* If you don't have a kubernetes cluster, you have two options: - - * You can get access to a managed kubernetes cluster with the cloud provider of your choice. - * You can install one if you have ssh access to a virtual machine, see :ref:`ansible-kubernetes` - -* If you don't have ``helm`` yet, see `Installing helm `__. - -Type ``helm version``, you should, if everything is configured correctly, see a result like this: - -:: - - version.BuildInfo{Version:"v3.1.1", GitCommit:"afe70585407b420d0097d07b21c47dc511525ac8", GitTreeState:"clean", GoVersion:"go1.13.8"} - - -In case ``kubectl version`` shows both Client and Server versions, but ``helm version`` does not show a Server version, you may need to run ``helm init``. The exact version (assuming `v2.X.X` - at the time of writing v3 is not yet supported) matters less as long as both Client and Server versions match (or are very close). - -How to start installing charts from wire --------------------------------------------------- - -Enable the wire charts helm repository: - -.. code:: shell - - helm repo add wire https://s3-eu-west-1.amazonaws.com/public.wire.com/charts - -(You can see available helm charts by running ``helm search repo wire/``. To see -new versions as time passes, you may need to run ``helm repo update``) - -Great! Now you can start installing. - -.. note:: - - all commands below can also take an extra ``--namespace `` if you don't want to install into the default kubernetes namespace. - -Watching changes as they happen --------------------------------------------------- - -Open a terminal and run - -.. code:: shell - - kubectl get pods -w - -This will block your terminal and show some things happening as you proceed through this guide. Keep this terminal open and open a second terminal. - -How to install in-memory databases and external components --------------------------------------------------------------- - -In your second terminal, first install databases: - -.. code:: shell - - helm upgrade --install databases-ephemeral wire/databases-ephemeral --wait - -You should see some pods being created in your first terminal as the above command completes. - -You can do the following two steps (mock aws services and demo smtp -server) in parallel with the above in two more terminals, or -sequentially after database-ephemeral installation has succeeded. - -.. code:: shell - - helm upgrade --install fake-aws wire/fake-aws --wait - helm upgrade --install smtp wire/demo-smtp --wait - -How to install wire-server itself ---------------------------------------- - -.. note:: - - The following makes use of overrides for helm charts. You may wish to read :ref:`understand-helm-overrides` first. - -Change back to the wire-server-deploy directory. Copy example demo values and secrets: - -.. code:: shell - - mkdir -p wire-server && cd wire-server - cp ../values/wire-server/demo-secrets.example.yaml secrets.yaml - cp ../values/wire-server/demo-values.example.yaml values.yaml - -Or, if you are not in wire-server-deploy, download example demo values and secrets: - -.. code:: shell - - mkdir -p wire-server && cd wire-server - curl -sSL https://raw.githubusercontent.com/wireapp/wire-server-deploy/master/values/wire-server/demo-secrets.example.yaml > secrets.yaml - curl -sSL https://raw.githubusercontent.com/wireapp/wire-server-deploy/master/values/wire-server/demo-values.example.yaml > values.yaml - -Open ``values.yaml`` and replace ``example.com`` and other domains and subdomains with domains of your choosing. Look for the ``# change this`` comments. You can try using ``sed -i 's/example.com//g' values.yaml``. - -Generate some secrets (if you are using the docker image from :ref:`ansible-kubernetes`, you should open a shell on the host system for this): - -.. code:: shell - - openssl rand -base64 64 | env LC_CTYPE=C tr -dc a-zA-Z0-9 | head -c 42 > restund.txt - docker run --rm quay.io/wire/alpine-intermediate /dist/zauth -m gen-keypair -i 1 > zauth.txt - -1. Add the generated secret from restund.txt to secrets.yaml under ``brig.secrets.turn.secret`` -2. add **both** the public and private parts from zauth.txt to secrets.yaml under ``brig.secrets.zAuth`` -3. Add the public key from zauth.txt **also** to secrets.yaml under ``nginz.secrets.zAuth.publicKeys`` - -You can do this with an editor, or using sed: - -.. code:: shell - - sed -i 's/secret:$/secret: content_of_restund.txt_file/' secrets.yaml - sed -i 's/publicKeys: ""/publicKeys: "public_key_from_zauth.txt_file"/' secrets.yaml - sed -i 's/privateKeys: ""/privateKeys: "private_key_from_zauth.txt_file"/' secrets.yaml - -Great, now try the installation: - -.. code:: shell - - helm upgrade --install wire-server wire/wire-server -f values.yaml -f secrets.yaml --wait - -.. include:: includes/helm_dns-ingress-troubleshooting.inc.rst diff --git a/docs/src/how-to/install/includes/dns-federation.rst b/docs/src/how-to/install/includes/dns-federation.rst deleted file mode 100644 index c25184ffbe..0000000000 --- a/docs/src/how-to/install/includes/dns-federation.rst +++ /dev/null @@ -1,43 +0,0 @@ -DNS setup for federation ------------------------- - -SRV record -^^^^^^^^^^ - -One prerequisite to enable federation is an `SRV record `__ as defined in `RFC -2782 `__ that needs to be set up to allow the wire-server to be -discovered by other Wire backends. See the documentation on :ref:`discovery in federation` for more -information on the role of discovery in federation. - -The fields of the SRV record need to be populated as follows - -* ``service``: ``wire-server-federator`` -* ``proto``: ``tcp`` -* ``name``: -* ``TTL``: e.g. 600 (10 minutes) in an initial phase. This can be set to a higher value (e.g. 86400) if your systems are stable and DNS records don't change a lot. -* ``priority``: anything. A good default value would be 0 -* ``weight``: >0 for your server to be reachable. A good default value could be 10 -* ``port``: ``443`` -* ``target``: - -To give an example, assuming - -* your federation :ref:`Backend Domain ` is ``example.com`` -* your domains for other services already set up follow the convention ``.wire.example.org`` - -then your federation :ref:`Infra Domain ` would be ``federator.wire.example.org``. - -The SRV record would look as follows: - -.. code-block:: bash - - # _service._proto.name. ttl IN SRV priority weight port target. - _wire-server-federator._tcp.example.com. 600 IN SRV 0 10 443 federator.wire.example.org. - -DNS A record for the federator -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Background: ``federator`` is the server component responsible for incoming and outgoing requests to other backend; but it is proxied on -the incoming requests by the ingress component on kubernetes as shown in :ref:`Federation Architecture` - -As mentioned in :ref:`DNS setup for Helm`, you also need a ``federator.`` record, which, alongside your other DNS records that point to the ingress component, also needs to point to the IP of your ingress, i.e. the IP you want to provide services on. diff --git a/docs/src/how-to/install/includes/helm_dns-ingress-troubleshooting.inc.rst b/docs/src/how-to/install/includes/helm_dns-ingress-troubleshooting.inc.rst index 90b9e1f3b5..610ca8c784 100644 --- a/docs/src/how-to/install/includes/helm_dns-ingress-troubleshooting.inc.rst +++ b/docs/src/how-to/install/includes/helm_dns-ingress-troubleshooting.inc.rst @@ -143,8 +143,6 @@ Next, we want to redirect port 443 to the port the nginx https ingress nodeport * Option 2: Use ansible to do that, run the `iptables playbook `__ -.. include:: ./includes/dns-federation.rst - Trying things out ----------------- diff --git a/docs/src/how-to/install/index.md b/docs/src/how-to/install/index.md new file mode 100644 index 0000000000..183215c603 --- /dev/null +++ b/docs/src/how-to/install/index.md @@ -0,0 +1,30 @@ +# Installing wire-server + +```{toctree} +:glob: true +:maxdepth: 2 + +How to plan an installation +Version requirements +dependencies +(demo) How to install kubernetes +(demo) How to install wire-server using Helm +(production) Introduction +(production) How to install kubernetes and databases +(production) How to configure AWS services +(production) How to install wire-server using Helm +(production) How to monitor wire-server +(production) How to see centralized logs for wire-server +(production) Other configuration options +Server and team feature settings +Messaging Layer Security (MLS) +Web app settings +sft +restund +configure-federation +tls +How to install and set up Legal Hold +Managing authentication with ansible +Using tinc +Troubleshooting during installation +``` diff --git a/docs/src/how-to/install/index.rst b/docs/src/how-to/install/index.rst deleted file mode 100644 index 03802f43c7..0000000000 --- a/docs/src/how-to/install/index.rst +++ /dev/null @@ -1,30 +0,0 @@ -Installing wire-server -======================= - -.. toctree:: - :maxdepth: 2 - :glob: - - How to plan an installation - Version requirements - dependencies - (demo) How to install kubernetes - (demo) How to install wire-server using Helm - (production) Introduction - (production) How to install kubernetes and databases - (production) How to configure AWS services - (production) How to install wire-server using Helm - (production) How to monitor wire-server - (production) How to see centralized logs for wire-server - (production) Other configuration options - Server and team feature settings - Messaging Layer Security (MLS) - Web app settings - sft - restund - configure-federation - tls - How to install and set up Legal Hold - Managing authentication with ansible - Using tinc - Troubleshooting during installation diff --git a/docs/src/how-to/install/kubernetes.md b/docs/src/how-to/install/kubernetes.md new file mode 100644 index 0000000000..1c4430eefd --- /dev/null +++ b/docs/src/how-to/install/kubernetes.md @@ -0,0 +1,85 @@ +(ansible-kubernetes)= + +# Installing kubernetes for a demo installation (on a single virtual machine) + +## How to set up your hosts.ini file + +Assuming a single virtual machine with a public IP address running Ubuntu 18.04, with at least 5 CPU cores and at least 8 GB of memory. + +Move to `wire-server-deploy/ansible`: + +```shell +cd ansible/ +``` + +Then: + +```{eval-rst} +.. include:: includes/ansible-authentication-blob.rst +``` + +## Passwordless authentication + +Presuming a fresh default Ubuntu 18.04 installation, the following steps will enable the Ansible playbook to run without specifying passwords. + +This presumes you named your default Ubuntu user "wire", and X.X.X.X is the IP or domain name of the target server Ansible will install Kubernetes on. + +On the client (from `wire-server-deploy/ansible`), run: + +```shell +ssh-keygen -f /root/.ssh/id_rsa -t rsa -P +ssh-copy-id wire@X.X.X.X +sed -i 's/# ansible_user = .../ansible_user = wire/g' inventory/demo/hosts.ini +``` + +And on the server (X.X.X.X), run: + +```shell +echo 'wire ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers +``` + +Then on the client: + +```shell +cp inventory/demo/hosts.example.ini inventory/demo/hosts.ini +``` + +Open hosts.ini and replace `X.X.X.X` with the IP address of your virtual machine that you use for ssh access. You can try using: + +```shell +sed -i 's/X.X.X.X/1.2.3.4/g' inventory/demo/hosts.ini +``` + +## Minio setup + +In the `inventory/demo/hosts.ini` file, edit the minio variables in `[minio:vars]` (`prefix`, `domain` and `deeplink_title`) +by replacing `example.com` with your own domain. + +## How to install kubernetes + +From `wire-server-deploy/ansible`: + +``` +ansible-playbook -i inventory/demo/hosts.ini kubernetes.yml -vv +``` + +When the playbook finishes correctly (which can take up to 20 minutes), you should have a folder `artifacts` containing a file `admin.conf`. Copy this file: + +``` +mkdir -p ~/.kube +cp artifacts/admin.conf ~/.kube/config +KUBECONFIG=~/.kube/config +``` + +Make sure you can reach the server: + +``` +kubectl version +``` + +should give output similar to this: + +``` +Client Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.2", GitCommit:"66049e3b21efe110454d67df4fa62b08ea79a19b", GitTreeState:"clean", BuildDate:"2019-05-16T16:23:09Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"} +Server Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.2", GitCommit:"66049e3b21efe110454d67df4fa62b08ea79a19b", GitTreeState:"clean", BuildDate:"2019-05-16T16:14:56Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"} +``` diff --git a/docs/src/how-to/install/kubernetes.rst b/docs/src/how-to/install/kubernetes.rst deleted file mode 100644 index d4e423dfa4..0000000000 --- a/docs/src/how-to/install/kubernetes.rst +++ /dev/null @@ -1,83 +0,0 @@ -.. _ansible-kubernetes: - -Installing kubernetes for a demo installation (on a single virtual machine) -============================================================================ - - -How to set up your hosts.ini file -------------------------------------- - -Assuming a single virtual machine with a public IP address running Ubuntu 18.04, with at least 5 CPU cores and at least 8 GB of memory. - -Move to ``wire-server-deploy/ansible``: - -.. code:: shell - - cd ansible/ - -Then: - -.. include:: includes/ansible-authentication-blob.rst - -Passwordless authentication ---------------------------- - -Presuming a fresh default Ubuntu 18.04 installation, the following steps will enable the Ansible playbook to run without specifying passwords. - -This presumes you named your default Ubuntu user "wire", and X.X.X.X is the IP or domain name of the target server Ansible will install Kubernetes on. - -On the client (from ``wire-server-deploy/ansible``), run: - -.. code:: shell - - ssh-keygen -f /root/.ssh/id_rsa -t rsa -P - ssh-copy-id wire@X.X.X.X - sed -i 's/# ansible_user = .../ansible_user = wire/g' inventory/demo/hosts.ini - -And on the server (X.X.X.X), run: - -.. code:: shell - - echo 'wire ALL=(ALL) NOPASSWD:ALL' | sudo tee -a /etc/sudoers - -Then on the client: - -.. code:: shell - - cp inventory/demo/hosts.example.ini inventory/demo/hosts.ini - -Open hosts.ini and replace `X.X.X.X` with the IP address of your virtual machine that you use for ssh access. You can try using: - -.. code:: shell - - sed -i 's/X.X.X.X/1.2.3.4/g' inventory/demo/hosts.ini - -Minio setup ------------ - -In the ``inventory/demo/hosts.ini`` file, edit the minio variables in ``[minio:vars]`` (``prefix``, ``domain`` and ``deeplink_title``) -by replacing ``example.com`` with your own domain. - -How to install kubernetes --------------------------- - -From ``wire-server-deploy/ansible``:: - - ansible-playbook -i inventory/demo/hosts.ini kubernetes.yml -vv - -When the playbook finishes correctly (which can take up to 20 minutes), you should have a folder ``artifacts`` containing a file ``admin.conf``. Copy this file:: - - mkdir -p ~/.kube - cp artifacts/admin.conf ~/.kube/config - KUBECONFIG=~/.kube/config - -Make sure you can reach the server:: - - kubectl version - -should give output similar to this:: - - Client Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.2", GitCommit:"66049e3b21efe110454d67df4fa62b08ea79a19b", GitTreeState:"clean", BuildDate:"2019-05-16T16:23:09Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"} - Server Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.2", GitCommit:"66049e3b21efe110454d67df4fa62b08ea79a19b", GitTreeState:"clean", BuildDate:"2019-05-16T16:14:56Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"} - - diff --git a/docs/src/how-to/install/logging.rst b/docs/src/how-to/install/logging.md similarity index 60% rename from docs/src/how-to/install/logging.rst rename to docs/src/how-to/install/logging.md index 5d9368c83c..ca4ea9341d 100644 --- a/docs/src/how-to/install/logging.rst +++ b/docs/src/how-to/install/logging.md @@ -1,182 +1,164 @@ -.. _logging: +(logging)= -Installing centralized logging dashboards using Kibana -======================================================== +# Installing centralized logging dashboards using Kibana -Introduction ------------- +## Introduction This page shows you how to install Elasticsearch, Kibana, and fluent-bit to aggregate and visualize the logs from wire-server components. -Status -------- +## Status Logging support is in active development as of September 2019, some logs may not be visible yet, and certain parts are not fully automated yet. -Prerequisites -------------- +## Prerequisites You need to have wire-server installed, see either of -* :ref:`helm` -* :ref:`helm_prod`. +- {ref}`helm` +- {ref}`helm-prod`. +## Installing required helm charts -Installing required helm charts --------------------------------- - - -Deploying Elasticsearch -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +### Deploying Elasticsearch Elasticsearch indexes the logs and makes them searchable. The following elasticsearch-ephemeral chart makes use of the disk space the pod happens to run on. -:: - - $ helm install --namespace wire/elasticsearch-ephemeral +``` +$ helm install --namespace wire/elasticsearch-ephemeral +``` Note that since we are not specifying a release name during helm install, it generates a 'verb-noun' pair, and uses it. Elasticsearch's chart does not use the release name of the helm chart in the pod name, sadly. -Deploying Elasticsearch-Curator -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +### Deploying Elasticsearch-Curator Elasticsearch-curator trims the logs that are contained in elasticsearch, so that your elasticsearch pod does not get too large, crash, and need to be re-built. -:: - - $ helm install --namespace wire/elasticsearch-curator +``` +$ helm install --namespace wire/elasticsearch-curator +``` Note that since we are not specifying a release name during helm install, it generates a 'verb-noun' pair, and uses it. If you look at your pod names, you can see this name prepended to your pods in 'kubectl -n get pods'. -Deploying Kibana -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:: +### Deploying Kibana - $ helm install --namespace wire/kibana +``` +$ helm install --namespace wire/kibana +``` Note that since we are not specifying a release name during helm install, it generates a 'verb-noun' pair, and uses it. If you look at your pod names, you can see this name prepended to your pods in 'kubectl -n get pods'. -Deploying fluent-bit -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:: +### Deploying fluent-bit - $ helm install --namespace wire/fluent-bit +``` +$ helm install --namespace wire/fluent-bit +``` -Configuring fluent-bit ----------------------- +## Configuring fluent-bit -.. note:: +```{note} +The following makes use of overrides for helm charts. You may wish to read {ref}`understand-helm-overrides` first. +``` - The following makes use of overrides for helm charts. You may wish to read :ref:`understand-helm-overrides` first. - -Per pod-template, you can specify what parsers ``fluent-bit`` needs to +Per pod-template, you can specify what parsers `fluent-bit` needs to use to interpret the pod's logs in a structured way. By default, it just parses them as plain text. But, you can change this using a pod annotation. E.g.: -:: - - apiVersion: v1 - kind: Pod - metadata: - name: brig - labels: - app: brig - annotations: - fluentbit.io/parser: json - spec: - containers: - - name: apache - image: edsiper/apache_logs - -You can also define your own custom parsers in our ``fluent-bit`` -chart's ``values.yml``. For example, we have one defined for ``nginz``. +``` +apiVersion: v1 +kind: Pod +metadata: + name: brig + labels: + app: brig + annotations: + fluentbit.io/parser: json +spec: + containers: + - name: apache + image: edsiper/apache_logs +``` + +You can also define your own custom parsers in our `fluent-bit` +chart's `values.yml`. For example, we have one defined for `nginz`. For more info, see : -https://github.com/fluent/fluent-bit-docs/blob/master/filter/kubernetes.md#kubernetes-annotations + Alternately, if there is already fluent-bit deployed in your environment, get the helm name for the deployment (verb-noun prepended to the pod name), and -:: - - $ helm upgrade --namespace wire/fluent-bit +``` +$ helm upgrade --namespace wire/fluent-bit +``` Note that since we are not specifying a release name during helm install, it generates a 'verb-noun' pair, and uses it. if you look at your pod names, you can see this name prepended to your pods in 'kubectl -n get pods'. -.. _post-install-kibana-setup: +(post-install-kibana-setup)= -Post-install kibana setup --------------------------- +## Post-install kibana setup Get the pod name for your kibana instance (not the one set up with fluent-bit), and -:: - - $ kubectl -n port-forward 5601:5601 +``` +$ kubectl -n port-forward 5601:5601 +``` go to 127.0.0.1:5601 in your web browser. 1. Click on 'discover'. -2. Use ``kubernetes_cluster-*`` as the Index pattern. +2. Use `kubernetes_cluster-*` as the Index pattern. 3. Click on 'Next step' 4. Click on the 'Time Filter field name' dropdown, and select - '@timestamp'. + '. 5. Click on 'create index patern'. - -Usage after installation -------------------------- +## Usage after installation Get the pod name for your kibana instance (not the one set up with fluent-bit), and -:: - - $ kubectl -n port-forward 5601:5601 +``` +$ kubectl -n port-forward 5601:5601 +``` Go to 127.0.0.1:5601 in your web browser. Click on 'discover' to view data. -.. _nuking-it-all: +(nuking-it-all)= -Nuking it all. --------------- +## Nuking it all. -Find the names of the helm releases for your pods (look at ``helm ls --all`` -and ``kubectl -n get pods`` , and run -``helm del --purge`` for each of them. +Find the names of the helm releases for your pods (look at `helm ls --all` +and `kubectl -n get pods` , and run +`helm del --purge` for each of them. Note: Elasticsearch does not use the name of the helm chart, and therefore is harder to identify. -Debugging ---------- - -:: +## Debugging - kubectl -n logs +``` +kubectl -n logs +``` -How this was developed -^^^^^^^^^^^^^^^^^^^^^^^^ +### How this was developed First, we deployed elasticsearch with the elasticsearch-ephemeral chart, then kibana. then we deployed fluent-bit, which set up a kibana of it's diff --git a/docs/src/how-to/install/monitoring.rst b/docs/src/how-to/install/monitoring.md similarity index 58% rename from docs/src/how-to/install/monitoring.rst rename to docs/src/how-to/install/monitoring.md index ea900526cc..18f5a8865b 100644 --- a/docs/src/how-to/install/monitoring.rst +++ b/docs/src/how-to/install/monitoring.md @@ -1,21 +1,19 @@ -.. _monitoring: +(monitoring)= -Monitoring wire-server using Prometheus and Grafana -======================================================= +# Monitoring wire-server using Prometheus and Grafana All wire-server helm charts offering prometheus metrics expose a `metrics.serviceMonitor.enabled` option. If these are set to true, the helm charts will install `ServiceMonitor` resources, which can be used to mark services for scraping by -[Prometheus Operator](https://prometheus-operator.dev/), -[Grafana Agent Operator](https://grafana.com/docs/grafana-cloud/kubernetes-monitoring/agent-k8s/), +\[Prometheus Operator\](), +\[Grafana Agent Operator\](), or similar prometheus-compatible tools. Refer to their documentation for installation. -Dashboards ------------------ +## Dashboards -Grafana dashboard configurations are included as JSON inside the ``dashboards`` +Grafana dashboard configurations are included as JSON inside the `dashboards` directory. You may import these via Grafana's web UI. diff --git a/docs/src/how-to/install/planning.rst b/docs/src/how-to/install/planning.md similarity index 55% rename from docs/src/how-to/install/planning.rst rename to docs/src/how-to/install/planning.md index 29e84f97a6..1c3b1a5f44 100644 --- a/docs/src/how-to/install/planning.rst +++ b/docs/src/how-to/install/planning.md @@ -1,10 +1,8 @@ -Implementation plan -==================================== +# Implementation plan There are two types of implementation: demo and production. -Demo installation (trying functionality out) ------------------------------------------------ +## Demo installation (trying functionality out) Please note that there is no way to migrate data from a demo installation to a production installation - it is really meant as a way @@ -14,36 +12,36 @@ Please note your data will be in-memory only and may disappear at any given mome What you need: -- a way to create **DNS records** for your domain name (e.g. - ``wire.example.com``) -- a way to create **SSL/TLS certificates** for your domain name (to allow - connecting via ``https://``) -- Either one of the following: +- a way to create **DNS records** for your domain name (e.g. + `wire.example.com`) - - A kubernetes cluster (some cloud providers offer a managed - kubernetes cluster these days). - - One single virtual machine running ubuntu 18.04 with at least 20 GB of disk, 8 GB of memory, and 8 CPU cores. +- a way to create **SSL/TLS certificates** for your domain name (to allow + connecting via `https://`) -A demo installation will look a bit like this: +- Either one of the following: + + - A kubernetes cluster (some cloud providers offer a managed + kubernetes cluster these days). + - One single virtual machine running ubuntu 18.04 with at least 20 GB of disk, 8 GB of memory, and 8 CPU cores. -.. figure:: img/architecture-demo.png +A demo installation will look a bit like this: - Demo installation (1 VM) +```{figure} img/architecture-demo.png +Demo installation (1 VM) +``` -Next steps for demo installation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +### Next steps for demo installation -If you already have a kubernetes cluster, your next step will be :ref:`helm`, otherwise, your next step will be :ref:`ansible-kubernetes` +If you already have a kubernetes cluster, your next step will be {ref}`helm`, otherwise, your next step will be {ref}`ansible-kubernetes` -.. _planning_prod: +(planning-prod)= -Production installation (persistent data, high-availability) --------------------------------------------------------------- +## Production installation (persistent data, high-availability) What you need: -- a way to create **DNS records** for your domain name (e.g. ``wire.example.com``) -- a way to create **SSL/TLS certificates** for your domain name (to allow connecting via ``https://wire.example.com``) +- a way to create **DNS records** for your domain name (e.g. `wire.example.com`) +- a way to create **SSL/TLS certificates** for your domain name (to allow connecting via `https://wire.example.com`) - A **kubernetes cluster with at least 3 worker nodes and at least 3 etcd nodes** (some cloud providers offer a managed kubernetes cluster these days) - minimum **17 virtual machines** for components outside kubernetes (cassandra, minio, elasticsearch, redis, restund) @@ -51,13 +49,15 @@ A recommended installation of Wire-server in any regular data centre, configured with high-availability will require the following virtual servers: +```{eval-rst} .. include:: includes/vm-table.rst +``` A production installation will look a bit like this: -.. figure:: img/architecture-server-ha.png - - Production installation in High-Availability mode +```{figure} img/architecture-server-ha.png +Production installation in High-Availability mode +``` If you use a private datacenter (not a cloud provider), the easiest is to have three physical servers, each with one virtual machine for each @@ -71,7 +71,6 @@ Ensure that your VMs have IP addresses that do not change. Avoid `10.x.x.x` network address schemes, and instead use something like `192.168.x.x` or `172.x.x.x`. This is because internally, Kubernetes already uses a `10.x.x.x` address scheme, creating a potential conflict. -Next steps for high-available production installation -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +### Next steps for high-available production installation -Your next step will be :ref:`ansible_vms` +Your next step will be {ref}`ansible-vms` diff --git a/docs/src/how-to/install/prod-intro.md b/docs/src/how-to/install/prod-intro.md new file mode 100644 index 0000000000..a908c14c30 --- /dev/null +++ b/docs/src/how-to/install/prod-intro.md @@ -0,0 +1,58 @@ +# Introduction + +```{warning} +It is *strongly recommended* to have followed and completed the demo installation {ref}`helm` before continuing with this page. The demo installation is simpler, and already makes you aware of a few things you need (TLS certs, DNS, a VM, ...). +``` + +```{note} +All required dependencies for doing an installation can be found here {ref}`dependencies`. +``` + +A production installation consists of several parts: + +Part 1 - you're on your own here, and need to create a set of VMs as detailed in {ref}`planning-prod` + +Part 2 ({ref}`ansible-vms`) deals with installing components directly on a set of virtual machines, such as kubernetes itself, as well as databases. It makes use of ansible to achieve that. + +Part 3 ({ref}`helm-prod`) is similar to the demo installation, and uses the tool `helm` to install software on top of kubernetes. + +Part 4 ({ref}`configuration-options`) details other possible configuration options and settings to fit your needs. + +## What will be installed by following these parts? + +- highly-available and persistent databases (cassandra, elasticsearch) + +- kubernetes + +- restund (audio/video calling) servers ( see also {ref}`understand-restund`) + +- wire-server (API) + \- user accounts, authentication, conversations + \- assets handling (images, files, ...) + \- notifications over websocket + \- single-sign-on with SAML + +- wire-webapp + + - fully functioning web client (like `https://app.wire.com`) + +- wire-account-pages + + - user account management (a few pages relating to e.g. password reset) + +## What will not be installed? + +- notifications over native push notification via [FCM](https://firebase.google.com/docs/cloud-messaging/)/[APNS](https://developer.apple.com/notifications/) + +## What will not be installed by default? + +- 3rd party proxying - requires accounts with third-party providers +- team-settings page for team management (including invitations, requires access to a private repository - get in touch with us for access) + +## Getting support + +[Get in touch](https://wire.com/pricing/). + +## Next steps for high-available production installation + +Your next step will be part 2, {ref}`ansible-vms` diff --git a/docs/src/how-to/install/prod-intro.rst b/docs/src/how-to/install/prod-intro.rst deleted file mode 100644 index 420b5fc296..0000000000 --- a/docs/src/how-to/install/prod-intro.rst +++ /dev/null @@ -1,60 +0,0 @@ -Introduction -============= - -.. warning:: - - It is *strongly recommended* to have followed and completed the demo installation :ref:`helm` before continuing with this page. The demo installation is simpler, and already makes you aware of a few things you need (TLS certs, DNS, a VM, ...). - -.. note:: - All required dependencies for doing an installation can be found here :ref:`dependencies`. - -A production installation consists of several parts: - -Part 1 - you're on your own here, and need to create a set of VMs as detailed in :ref:`planning_prod` - -Part 2 (:ref:`ansible_vms`) deals with installing components directly on a set of virtual machines, such as kubernetes itself, as well as databases. It makes use of ansible to achieve that. - -Part 3 (:ref:`helm_prod`) is similar to the demo installation, and uses the tool ``helm`` to install software on top of kubernetes. - -Part 4 (:ref:`configuration_options`) details other possible configuration options and settings to fit your needs. - -What will be installed by following these parts? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- highly-available and persistent databases (cassandra, elasticsearch) -- kubernetes -- restund (audio/video calling) servers ( see also :ref:`understand-restund`) -- wire-server (API) - - user accounts, authentication, conversations - - assets handling (images, files, ...) - - notifications over websocket - - single-sign-on with SAML - -- wire-webapp - - - fully functioning web client (like ``https://app.wire.com``) - -- wire-account-pages - - - user account management (a few pages relating to e.g. password reset) - -What will not be installed? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- notifications over native push notification via `FCM `__/`APNS `__ - -What will not be installed by default? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -- 3rd party proxying - requires accounts with third-party providers -- team-settings page for team management (including invitations, requires access to a private repository - get in touch with us for access) - -Getting support -^^^^^^^^^^^^^^^^ - -`Get in touch `__. - -Next steps for high-available production installation -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Your next step will be part 2, :ref:`ansible_vms` diff --git a/docs/src/how-to/install/restund.md b/docs/src/how-to/install/restund.md new file mode 100644 index 0000000000..90a616b105 --- /dev/null +++ b/docs/src/how-to/install/restund.md @@ -0,0 +1,80 @@ +(install-restund)= + +# Installing Restund + +## Background + +Restund servers allow two users on different networks to have a Wire audio or video call. + +Please refer to the following {ref}`section to better understand Restund and how it works `. + +## Installation instructions + +To Install Restund, do the following: + +1. In your `hosts.ini` file, in the `[restund:vars]` section, set + the `restund_network_interface` to the name of the interface + you want restund to talk to clients on. This value defaults to the + `default_ipv4_address`, with a fallback to `eth0`. +2. (optional) `restund_peer_udp_advertise_addr=Y.Y.Y.Y`: set this to + the IP to advertise for other restund servers if different than the + ip on the 'restund_network_interface'. If using + 'restund_peer_udp_advertise_addr', make sure that UDP (!) traffic + from any restund server (including itself) can reach that IP (for + `restund <-> restund` communication). This should only be necessary + if you're installing restund on a VM that is reachable on a public IP + address but the process cannot bind to that public IP address + directly (e.g. on AWS VPC VM). If unset, `restund <-> restund` UDP + traffic will default to the IP in the `restund_network_interface`. + +```ini +[all] +(...) +restund01 ansible_host=X.X.X.X + +(...) + +[all:vars] +## Set the network interface name for restund to bind to if you have more than one network interface +## If unset, defaults to the ansible_default_ipv4 (if defined) otherwise to eth0 +restund_network_interface = eth0 + +(see `defaults/main.yml `__ for a full list of variables to change if necessary) +``` + +3. Place a copy of the PEM formatted certificate and key you are going + to use for TLS communication to the restund server in + `/tmp/tls_cert_and_priv_key.pem`. Remove it after you have + completed deploying restund with ansible. +4. Use Ansible to actually install using the restund playbook: + +```bash +ansible-playbook -i hosts.ini restund.yml -vv +``` + +For information on setting up and using ansible-playbook to install Wire components, see {ref}`this page `. + +### Private Subnets + +By default, Restund is configured with a firewall that filters-out CIDR networks. + +If you need to enable Restund to connect to a CIDR addressed host or network, you can specify a list of private subnets in [CIDR format](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing), which will override Restund's firewall's default settings of filtering-out CIDR networks. + +You do this by setting the `restund_allowed_private_network_cidrs` option of the `[restund:vars]` section of the ansible inventory file ([for example this file](https://github.com/wireapp/wire-server-deploy/blob/master/ansible/inventory/prod/hosts.example.ini#L72)): + +```ini +[restund:vars] +## Set the network interface name for restund to bind to if you have more than one network interface +## If unset, defaults to the ansible_default_ipv4 (if defined) otherwise to eth0 +# restund_network_interface = eth0 +restund_allowed_private_network_cidrs=192.168.0.1/32 +``` + +This is needed, for example, to allow talking to the logging server if it is on a separate network: + +The private subnets only need to override the RFC-defined private networks, which Wire firewalls off by default: + +- 192.168.x.x +- 10.x.x.x +- 172.16.x.x - 172.31.x.x +- Etc... diff --git a/docs/src/how-to/install/restund.rst b/docs/src/how-to/install/restund.rst deleted file mode 100644 index 732f0d0e26..0000000000 --- a/docs/src/how-to/install/restund.rst +++ /dev/null @@ -1,88 +0,0 @@ -.. _install-restund: - -Installing Restund -================== - -Background -~~~~~~~~~~ - -Restund servers allow two users on different networks to have a Wire audio or video call. - -Please refer to the following :ref:`section to better understand Restund and how it works `. - -Installation instructions -~~~~~~~~~~~~~~~~~~~~~~~~~ - -To Install Restund, do the following: - - -1. In your ``hosts.ini`` file, in the ``[restund:vars]`` section, set - the ``restund_network_interface`` to the name of the interface - you want restund to talk to clients on. This value defaults to the - ``default_ipv4_address``, with a fallback to ``eth0``. - -2. (optional) ``restund_peer_udp_advertise_addr=Y.Y.Y.Y``: set this to - the IP to advertise for other restund servers if different than the - ip on the 'restund_network_interface'. If using - 'restund_peer_udp_advertise_addr', make sure that UDP (!) traffic - from any restund server (including itself) can reach that IP (for - ``restund <-> restund`` communication). This should only be necessary - if you're installing restund on a VM that is reachable on a public IP - address but the process cannot bind to that public IP address - directly (e.g. on AWS VPC VM). If unset, ``restund <-> restund`` UDP - traffic will default to the IP in the ``restund_network_interface``. - -.. code:: ini - - [all] - (...) - restund01 ansible_host=X.X.X.X - - (...) - - [all:vars] - ## Set the network interface name for restund to bind to if you have more than one network interface - ## If unset, defaults to the ansible_default_ipv4 (if defined) otherwise to eth0 - restund_network_interface = eth0 - - (see `defaults/main.yml `__ for a full list of variables to change if necessary) - -3. Place a copy of the PEM formatted certificate and key you are going - to use for TLS communication to the restund server in - ``/tmp/tls_cert_and_priv_key.pem``. Remove it after you have - completed deploying restund with ansible. - -4. Use Ansible to actually install using the restund playbook: - -.. code:: bash - - ansible-playbook -i hosts.ini restund.yml -vv - -For information on setting up and using ansible-playbook to install Wire components, see :ref:`this page `. - -Private Subnets ---------------- - -By default, Restund is configured with a firewall that filters-out CIDR networks. - -If you need to enable Restund to connect to a CIDR addressed host or network, you can specify a list of private subnets in `CIDR format `__, which will override Restund's firewall's default settings of filtering-out CIDR networks. - -You do this by setting the ``restund_allowed_private_network_cidrs`` option of the ``[restund:vars]`` section of the ansible inventory file (`for example this file `__): - -.. code:: ini - - [restund:vars] - ## Set the network interface name for restund to bind to if you have more than one network interface - ## If unset, defaults to the ansible_default_ipv4 (if defined) otherwise to eth0 - # restund_network_interface = eth0 - restund_allowed_private_network_cidrs=192.168.0.1/32 - -This is needed, for example, to allow talking to the logging server if it is on a separate network: - -The private subnets only need to override the RFC-defined private networks, which Wire firewalls off by default: - -* 192.168.x.x -* 10.x.x.x -* 172.16.x.x - 172.31.x.x -* Etc... - diff --git a/docs/src/how-to/install/sft.rst b/docs/src/how-to/install/sft.md similarity index 67% rename from docs/src/how-to/install/sft.rst rename to docs/src/how-to/install/sft.md index 2824d6827a..e4560c7216 100644 --- a/docs/src/how-to/install/sft.rst +++ b/docs/src/how-to/install/sft.md @@ -1,125 +1,116 @@ -.. _install-sft: +(install-sft)= -Installing Conference Calling 2.0 (aka SFT) -=========================================== +# Installing Conference Calling 2.0 (aka SFT) -Background -~~~~~~~~~~ +## Background -Please refer to the following :ref:`section to better understand SFT and how it works `. +Please refer to the following {ref}`section to better understand SFT and how it works `. +### As part of the wire-server umbrella chart -As part of the wire-server umbrella chart ------------------------------------------ +`` sftd` `` will be installed as part of the `wire-server` umbrella chart if you set `tags.sftd: true` -`sftd`` will be installed as part of the ``wire-server`` umbrella chart if you set `tags.sftd: true` +In your `./values/wire-server/values.yaml` file you should set the following settings: -In your ``./values/wire-server/values.yaml`` file you should set the following settings: +```yaml +tags: + sftd: true -.. code:: yaml +sftd: + host: sftd.example.com # Replace example.com with your domain + allowOrigin: webapp.example.com # Should be the address you used for the webapp deployment +``` - tags: - sftd: true +In your `secrets.yaml` you should set the TLS keys for sftd domain: - sftd: - host: sftd.example.com # Replace example.com with your domain - allowOrigin: webapp.example.com # Should be the address you used for the webapp deployment +```yaml +sftd: + tls: + crt: | + + key: | + +``` -In your ``secrets.yaml`` you should set the TLS keys for sftd domain: +You should also make sure that you configure brig to know about the SFT server in your `./values/wire-server/values.yaml` file: -.. code:: yaml - - sftd: - tls: - crt: | - - key: | - - -You should also make sure that you configure brig to know about the SFT server in your ``./values/wire-server/values.yaml`` file: - -.. code:: yaml - - brig: - optSettings: - setSftStaticUrl: "https://sftd.example.com:443" +```yaml +brig: + optSettings: + setSftStaticUrl: "https://sftd.example.com:443" +``` Now you can deploy as usual: -.. code:: shell +```shell +helm upgrade wire-server wire/wire-server --values ./values/wire-server/values.yaml +``` - helm upgrade wire-server wire/wire-server --values ./values/wire-server/values.yaml - - -Standalone ----------- +### Standalone The SFT component is also shipped as a separate helm chart. Installation is similar to installing -the charts as in :ref:`helm_prod`. +the charts as in {ref}`helm-prod`. Some people might want to run SFT separately, because the deployment lifecycle for the SFT is a bit more intricate. For example, -if you want to avoid dropping calls during an upgrade, you'd set the ``terminationGracePeriodSeconds`` of the SFT to a high number, to wait -for calls to drain before updating to the new version (See `technical documentation `__). that would cause your otherwise snappy upgrade of the ``wire-server`` chart to now take a long time, as it waits for all -the SFT servers to drain. If this is a concern for you, we advice installing ``sftd`` as a separate chart. - -It is important that you disable ``sftd`` in the ``wire-server`` umbrella chart, by setting this in your ``./values/wire-server/values.yaml`` file +if you want to avoid dropping calls during an upgrade, you'd set the `terminationGracePeriodSeconds` of the SFT to a high number, to wait +for calls to drain before updating to the new version (See [technical documentation](https://github.com/wireapp/wire-server/blob/develop/charts/sftd/README.md)). that would cause your otherwise snappy upgrade of the `wire-server` chart to now take a long time, as it waits for all +the SFT servers to drain. If this is a concern for you, we advice installing `sftd` as a separate chart. -.. code:: yaml +It is important that you disable `sftd` in the `wire-server` umbrella chart, by setting this in your `./values/wire-server/values.yaml` file - tags: - sftd: false +```yaml +tags: + sftd: false +``` +By default `sftd` doesn't need to set that many options, so we define them inline. However, you could of course also set these values in a `values.yaml` file. -By default ``sftd`` doesn't need to set that many options, so we define them inline. However, you could of course also set these values in a ``values.yaml`` file. +SFT will deploy a Kubernetes Ingress on `$SFTD_HOST`. Make sure that the domain name `$SFTD_HOST` points to your ingress IP as set up in {ref}`helm-prod`. The SFT also needs to be made aware of the domain name of the webapp that you set up in {ref}`helm-prod` for setting up the appropriate CSP headers. -SFT will deploy a Kubernetes Ingress on ``$SFTD_HOST``. Make sure that the domain name ``$SFTD_HOST`` points to your ingress IP as set up in :ref:`helm_prod`. The SFT also needs to be made aware of the domain name of the webapp that you set up in :ref:`helm_prod` for setting up the appropriate CSP headers. - -.. code:: shell - - export SFTD_HOST=sftd.example.com - export WEBAPP_HOST=webapp.example.com +```shell +export SFTD_HOST=sftd.example.com +export WEBAPP_HOST=webapp.example.com +``` Now you can install the chart: -.. code:: shell - - helm upgrade --install sftd wire/sftd --set - helm install sftd wire/sftd \ - --set host=$SFTD_HOST \ - --set allowOrigin=https://$WEBAPP_HOST \ - --set-file tls.crt=/path/to/tls.crt \ - --set-file tls.key=/path/to/tls.key - -You should also make sure that you configure brig to know about the SFT server, in the ``./values/wire-server/values.yaml`` file: +```shell +helm upgrade --install sftd wire/sftd --set +helm install sftd wire/sftd \ + --set host=$SFTD_HOST \ + --set allowOrigin=https://$WEBAPP_HOST \ + --set-file tls.crt=/path/to/tls.crt \ + --set-file tls.key=/path/to/tls.key +``` -.. code:: yaml +You should also make sure that you configure brig to know about the SFT server, in the `./values/wire-server/values.yaml` file: - brig: - optSettings: - setSftStaticUrl: "https://sftd.example.com:443" +```yaml +brig: + optSettings: + setSftStaticUrl: "https://sftd.example.com:443" +``` -And then roll-out the change to the ``wire-server`` chart +And then roll-out the change to the `wire-server` chart -.. code:: shell +```shell +helm upgrade wire-server wire/wire-server --values ./values/wire-server/values.yaml +``` - helm upgrade wire-server wire/wire-server --values ./values/wire-server/values.yaml +For more advanced setups please refer to the [technical documentation](https://github.com/wireapp/wire-server/blob/develop/charts/sftd/README.md). -For more advanced setups please refer to the `technical documentation `__. +(install-sft-firewall-rules)= +### Firewall rules -.. _install-sft-firewall-rules: - -Firewall rules --------------- - -The SFT allocates media addresses in the UDP :ref:`default port range `. Ingress and +The SFT allocates media addresses in the UDP {ref}`default port range `. Ingress and egress traffic should be allowed for this range. Furthermore the SFT needs to be -able to reach the :ref:`Restund server `, as it uses STUN and TURN in cases the client +able to reach the {ref}`Restund server `, as it uses STUN and TURN in cases the client can not directly connect to the SFT. In practise this means the SFT should -allow ingress and egress traffic on the UDP :ref:`default port range ` from and -to both, clients and :ref:`Restund servers `. +allow ingress and egress traffic on the UDP {ref}`default port range ` from and +to both, clients and {ref}`Restund servers `. -*For more information on this port range, how to read and change it, and how to configure your firewall, please see* :ref:`this note `. +*For more information on this port range, how to read and change it, and how to configure your firewall, please see* {ref}`this note `. The SFT also has an HTTP interface for initializing (allocation) or joining (signaling) a call. This is exposed through the ingress controller as an HTTPS service. @@ -131,6 +122,7 @@ An SFT instance does **not** communicate with other SFT instances, TURN does tal Recapitulation table: +```{eval-rst} +----------------------------+-------------+-------------+-----------+----------+-----------------------------------------------------------------------------+--------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Name | Origin | Destination | Direction | Protocol | Ports | Action (Policy) | Description | +============================+=============+=============+===========+==========+=============================================================================+======================================+===============================================================================================================================================================================================+ @@ -146,6 +138,6 @@ Recapitulation table: +----------------------------+-------------+-------------+-----------+----------+-----------------------------------------------------------------------------+--------------------------------------+ | | Allowing SFT media egress | Here | Anny | Outgoing | UDP | 32768-61000 | Allow | | +----------------------------+-------------+-------------+-----------+----------+-----------------------------------------------------------------------------+--------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +``` - -*For more information, please refer to the source code of the Ansible role:* `sft-server `__. +*For more information, please refer to the source code of the Ansible role:* [sft-server](https://github.com/wireapp/ansible-sft/blob/develop/roles/sft-server/tasks/traffic.yml). diff --git a/docs/src/how-to/install/tls.md b/docs/src/how-to/install/tls.md new file mode 100644 index 0000000000..f3a044597a --- /dev/null +++ b/docs/src/how-to/install/tls.md @@ -0,0 +1,52 @@ +(tls)= + +# Configure TLS ciphers + +The following table lists recommended ciphers for TLS server setups, which should be used in wire deployments. + +| Cipher | Version | Wire default | [BSI TR-02102-2] | [Mozilla TLS Guideline] | +| ----------------------------- | ------- | ------------ | ---------------- | ----------------------- | +| ECDHE-ECDSA-AES128-GCM-SHA256 | TLSv1.2 | no | **yes** | intermediate | +| ECDHE-RSA-AES128-GCM-SHA256 | TLSv1.2 | no | **yes** | intermediate | +| ECDHE-ECDSA-AES256-GCM-SHA384 | TLSv1.2 | **yes** | **yes** | intermediate | +| ECDHE-RSA-AES256-GCM-SHA384 | TLSv1.2 | **yes** | **yes** | intermediate | +| ECDHE-ECDSA-CHACHA20-POLY1305 | TLSv1.2 | no | no | intermediate | +| ECDHE-RSA-CHACHA20-POLY1305 | TLSv1.2 | no | no | intermediate | +| TLS_AES_128_GCM_SHA256 | TLSv1.3 | **yes** | **yes** | **modern** | +| TLS_AES_256_GCM_SHA384 | TLSv1.3 | **yes** | **yes** | **modern** | +| TLS_CHACHA20_POLY1305_SHA256 | TLSv1.3 | no | no | **modern** | + +```{note} +If you enable TLSv1.3, openssl does always enable the three default cipher suites for TLSv1.3. +Therefore it is not necessary to add them to openssl based configurations. +``` + +(ingress-traffic)= + +## Ingress Traffic (wire-server) + +The list of TLS ciphers for incoming requests is limited by default to the [following](https://github.com/wireapp/wire-server/blob/master/charts/nginx-ingress-controller/values.yaml#L7) (for general server-certificates, both for federation and client API), and can be overridden on your installation if needed. + +## Egress Traffic (wire-server/federation) + +The list of TLS ciphers for outgoing federation requests is currently hardcoded, the list is [here](https://github.com/wireapp/wire-server/blob/master/services/federator/src/Federator/Remote.hs#L164-L180). + +## SFTD (ansible) + +The list of TLS ciphers for incoming SFT requests (and metrics) are defined in ansible templates [sftd.vhost.conf.j2](https://github.com/wireapp/ansible-sft/blob/develop/roles/sft-server/templates/sftd.vhost.conf.j2#L19) and [metrics.vhost.conf.j2](https://github.com/wireapp/ansible-sft/blob/develop/roles/sft-server/templates/metrics.vhost.conf.j2#L13). + +## SFTD (kubernetes) + +SFTD deployed via kubernetes uses `kubernetes.io/ingress` for ingress traffic, configured in [ingress.yaml](https://github.com/wireapp/wire-server/blob/develop/charts/sftd/templates/ingress.yaml). +Kubernetes based deployments make use of the settings from {ref}`ingress-traffic`. + +## Restund (ansible) + +The list of TLS ciphers for "TLS over TCP" TURN (and metrics) are defined in ansible templates [nginx-stream.conf.j2](https://github.com/wireapp/ansible-restund/blob/master/templates/nginx-stream.conf.j2#L25) and [nginx-metrics.conf.j2](https://github.com/wireapp/ansible-restund/blob/master/templates/nginx-metrics.conf.j2#L15). + +## Restund (kubernetes) + +[Kubernetes restund](https://github.com/wireapp/wire-server/tree/develop/charts/restund) deployment does not provide TLS connectivity. + +[bsi tr-02102-2]: https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.pdf +[mozilla tls guideline]: https://wiki.mozilla.org/Security/Server_Side_TLS diff --git a/docs/src/how-to/install/tls.rst b/docs/src/how-to/install/tls.rst deleted file mode 100644 index 8adac3d525..0000000000 --- a/docs/src/how-to/install/tls.rst +++ /dev/null @@ -1,60 +0,0 @@ -.. _tls: - -Configure TLS ciphers -======================= - -The following table lists recommended ciphers for TLS server setups, which should be used in wire deployments. - - -============================= ======= ============ ================= ======================== -Cipher Version Wire default `BSI TR-02102-2`_ `Mozilla TLS Guideline`_ -============================= ======= ============ ================= ======================== -ECDHE-ECDSA-AES128-GCM-SHA256 TLSv1.2 no **yes** intermediate -ECDHE-RSA-AES128-GCM-SHA256 TLSv1.2 no **yes** intermediate -ECDHE-ECDSA-AES256-GCM-SHA384 TLSv1.2 **yes** **yes** intermediate -ECDHE-RSA-AES256-GCM-SHA384 TLSv1.2 **yes** **yes** intermediate -ECDHE-ECDSA-CHACHA20-POLY1305 TLSv1.2 no no intermediate -ECDHE-RSA-CHACHA20-POLY1305 TLSv1.2 no no intermediate -TLS_AES_128_GCM_SHA256 TLSv1.3 **yes** **yes** **modern** -TLS_AES_256_GCM_SHA384 TLSv1.3 **yes** **yes** **modern** -TLS_CHACHA20_POLY1305_SHA256 TLSv1.3 no no **modern** -============================= ======= ============ ================= ======================== - - -.. _bsi tr-02102-2: https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.pdf -.. _mozilla tls guideline: https://wiki.mozilla.org/Security/Server_Side_TLS - -.. note:: - If you enable TLSv1.3, openssl does always enable the three default cipher suites for TLSv1.3. - Therefore it is not necessary to add them to openssl based configurations. - -.. _ingress traffic: - -Ingress Traffic (wire-server) ------------------------------ -The list of TLS ciphers for incoming requests is limited by default to the `following `_ (for general server-certificates, both for federation and client API), and can be overridden on your installation if needed. - - -Egress Traffic (wire-server/federation) ---------------------------------------- -The list of TLS ciphers for outgoing federation requests is currently hardcoded, the list is `here `_. - - -SFTD (ansible) --------------- -The list of TLS ciphers for incoming SFT requests (and metrics) are defined in ansible templates `sftd.vhost.conf.j2 `_ and `metrics.vhost.conf.j2 `_. - -SFTD (kubernetes) ------------------ -SFTD deployed via kubernetes uses ``kubernetes.io/ingress`` for ingress traffic, configured in `ingress.yaml `_. -Kubernetes based deployments make use of the settings from :ref:`ingress traffic`. - - -Restund (ansible) ------------------ - -The list of TLS ciphers for "TLS over TCP" TURN (and metrics) are defined in ansible templates `nginx-stream.conf.j2 `_ and `nginx-metrics.conf.j2 `_. - -Restund (kubernetes) --------------------- -`Kubernetes restund `_ deployment does not provide TLS connectivity. diff --git a/docs/src/how-to/install/troubleshooting.md b/docs/src/how-to/install/troubleshooting.md new file mode 100644 index 0000000000..7aa9f80479 --- /dev/null +++ b/docs/src/how-to/install/troubleshooting.md @@ -0,0 +1,265 @@ +# Troubleshooting during installation + +## Problems with CORS on the web based applications (webapp, team-settings, account-pages) + +If you have installed wire-server, but the web application page in your browser has connection problems and throws errors in the console such as `"Refused to connect to 'https://assets.example.com' because it violates the following Content Security Policies"`, make sure to check that you have configured the `CSP_EXTRA_` environment variables. + +In the file that you use as override when running `helm install/update -f ` (using the webapp as an example): + +```yaml +webapp: + # ... other settings... + envVars: + # ... other environment variables ... + CSP_EXTRA_CONNECT_SRC: "https://*.example.com, wss://*.example.com" + CSP_EXTRA_IMG_SRC: "https://*.example.com" + CSP_EXTRA_SCRIPT_SRC: "https://*.example.com" + CSP_EXTRA_DEFAULT_SRC: "https://*.example.com" + CSP_EXTRA_FONT_SRC: "https://*.example.com" + CSP_EXTRA_FRAME_SRC: "https://*.example.com" + CSP_EXTRA_MANIFEST_SRC: "https://*.example.com" + CSP_EXTRA_OBJECT_SRC: "https://*.example.com" + CSP_EXTRA_MEDIA_SRC: "https://*.example.com" + CSP_EXTRA_PREFETCH_SRC: "https://*.example.com" + CSP_EXTRA_STYLE_SRC: "https://*.example.com" + CSP_EXTRA_WORKER_SRC: "https://*.example.com" +``` + +For more info, you can have a look at respective charts values files, i.e.: + +> - [charts/account-pages/values.yaml](https://github.com/wireapp/wire-server/blob/develop/charts/account-pages/values.yaml) +> - [charts/team-settings/values.yaml](https://github.com/wireapp/wire-server/blob/develop/charts/team-settings/values.yaml) +> - [charts/webapp/values.yaml](https://github.com/wireapp/wire-server/blob/develop/charts/webapp/values.yaml) + +## Problems with ansible and python versions + +If for instance the following fails: + +``` +ansible all -i hosts.ini -m shell -a "echo hello" +``` + +If your target machine only has python 3 (not python 2.7), you can tell ansible to use python 3 by default, by specifying `ansible_python_interpreter`: + +```ini +# hosts.ini + +[all] +server1 ansible_host=1.2.3.4 + + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 +``` + +(python 3 may not be supported by all ansible modules yet) + +## Flaky issues with Cassandra (failed QUORUMs, etc.) + +Cassandra is *very* picky about time! Ensure that NTP is properly set up on all nodes. Particularly for Cassandra *DO NOT* use anything else other than ntp. Here are some helpful blogs that explain why: + +> - +> - +> - + +How can I ensure that I have correctly setup NTP on my machine(s)? Have a look at [this ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/cassandra-verify-ntp.yml) + +## I deployed `demo-smtp` but I'm not receiving any verification emails + +1. Check whether brig deployed successfully (brig pod(s) should be in state *Running*) + + ``` + kubectl get pods -o wide + ``` + +2. Inspect Brig logs + + ``` + kubectl logs $BRING_POD_NAME + ``` + +3. The receiving email server might refuse to accept any email sent by the `demo-smtp` server, due to not being + a trusted origin. You may want to set up one of the following email verification mechanisms. + +- [SFP](https://en.wikipedia.org/wiki/Sender_Policy_Framework) +- [DKIM](https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail) +- [DMARC](https://en.wikipedia.org/wiki/DMARC) + +4. You may want to adjust the SMTP configuration for Brig (`wire-server/[values,secrets].yaml`). + +```yaml +brig: + config: + smtp: + host: 'demo-smtp' + port: 25 + connType: 'plain' +``` + +```yaml +brig: + secrets: + smtpPassword: dummyPassword +``` + +(Don't forget to apply the changes with `helm upgrade wire-server wire/wire-server -f values.yaml -f secrets.yaml`) + +## I deployed `demo-smtp` and I want to skip email configuration and retrieve verification codes directly + +If the only thing you need demo-smtp for is sending yourself verification codes to create a test account, it might be simpler and faster to just skip SMTP configuration, and simply retrieve the code internally right after it is sent, while it is in the outbound email queue. + +To do this, click create a user/account/team, or if you already have, click on `Resend Code`: + +```{figure} img/code-input.png +The code input interface +``` + +Then run the following command + +``` +kubectl exec $(kubectl get pod -lapp=demo-smtp | grep demo | awk '{print $1;}') -- sh -c 'cat /var/spool/exim4/input/* | grep -Po "^\\d{6}$" ' +``` + +Or step by step: + +1. Get the name of the pod + + ``` + kubectl get pod -lapp=demo-smtp + ``` + +Which will give you a result that looks something like this + +``` +> kubectl get pod -lapp=demo-smtp +NAME READY STATUS RESTARTS AGE +demo-smtp-85557f6877-qxk2p 1/1 Running 0 80m +``` + +In which case, the pod name is `demo-smtp-85557f6877-qxk2p`, which replaces \ in the next command. + +2. Then get the content of emails and extract the code + + ``` + kubectl exec -- sh -c 'head -n 15 /var/spool/exim4/input/* ' + ``` + +Which will give you the content of sent emails, including the code + +``` +> kubectl exec demo-smtp-85557f6877-qxk2p -- sh -c 'head -n 15 /var/spool/exim4/input/* ' +==> /var/spool/exim4/input/1mECxm-000068-28-D <== +1mECxm-000068-28-D +--Y3mymuwB5Y +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: quoted-printable +[https://wire=2Ecom/p/img/email/logo-email-black=2Epng] +VERIFY YOUR EMAIL +myemail@gmail=2Ecom was used to register on Wire=2E Enter this code to v= +erify your email and create your account=2E +022515 +``` + +This means the code is `022515`, simply enter it in the interface. + +If the email has already been sent out, it's possible the queue will be empty. + +If that is the case, simply click the "Resend Code" link in the interface, then quickly re-send the command, a new email should now be present. + +## Obtaining Brig logs, and the format of different team/user events + +To obtain brig logs, simply run + +``` +kubectl logs $(kubectl get pods | grep brig | awk '{print $1;}' | head -n 1) +``` + +You will get log entries for various different types of events that happen, for example: + +1. User creation + + ``` + {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Creating user"]} + ``` + +2. Activation key creation + + ``` + {"activation.code":"949721","activation.key":"p8o032Ljqhjgcea9R0AAnOeiUniGm63BrY9q_aeS1Cc=","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Activating"]} + ``` + +3. Activation of a new user + + ``` + {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","User activated"]} + ``` + +4. User indexing + + ``` + {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","logger":"index.brig","msgs":["I","Indexing user"]} + ``` + +5. Team creation + + ``` + {"email_sha256":"a7ca34df62e3aa18e071e6bd4740009ce7a25278869badc1ad8f6afda792d427","team":"6ef03a2b-34b5-4b65-8d72-1e4fc7697553","user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","module":"Brig.API.Public","fn":"Brig.API.Public.createUser","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Sucessfully created user"]} + ``` + +6. Invitation sent + + ``` + {"invitation_code":"hJuh1C1PzMkgtesAYZZ4SZrP5xO-xM_m","email_sha256":"eef48a690436699c653110387455a4afe93ce29febc348acd20f6605787956e6","team":"6ef03a2b-34b5-4b65-8d72-1e4fc7697553","module":"Brig.Team.API","fn":"Brig.Team.API.createInvitationPublic","request":"c43440074629d802a199464dd892cd92","msgs":["I","Succesfully created invitation"]} + ``` + +## Diagnosing and addressing bad network/disconnect issues + +### Diagnosis + +If you are experiencing bad network/disconnection issues, here is how to obtain the cause from the client log files: + +In the Web client, the connection state handler logs the disconnected state as reported by WebRTC as: + +``` +flow(...): connection_handler: disconnected, starting disconnect timer +``` + +On mobile, the output in the log is slightly different: + +``` +pf(...): ice connection state: Disconnected +``` + +And when the timer expires and the connection is not re-established: + +``` +ecall(...): mf_restart_handler: triggering restart due to network drop +``` + +If the attempt to reconnect then fails you will likely see the following: + +``` +ecall(...): connection timeout after 10000 milliseconds +``` + +If the connection to the SFT ({ref}`understand-sft`) server is considered lost due to missing ping messages from a non-functionning or delayed data channel or a failure to receive/decrypt media you will see: + +``` +ccall(...): reconnect +``` + +Then followed by these values: + +``` +cp: received CONFPART message YES/NO +da: decrypt attempted YES/NO +ds: decrypt successful YES/NO +att: number of reconnect attempts +p: the expected ping (how many pings have not returned) +``` + +### Configuration + +Question: Are the connection values for bad networks/disconnect configurable on on-prem? + +Answer: The values are not currently configurable, they are built into the clients at compile time, we do have a mechanism for sending calling configs to the clients but these values are not currently there. diff --git a/docs/src/how-to/install/troubleshooting.rst b/docs/src/how-to/install/troubleshooting.rst deleted file mode 100644 index 79adc61f52..0000000000 --- a/docs/src/how-to/install/troubleshooting.rst +++ /dev/null @@ -1,255 +0,0 @@ -Troubleshooting during installation -------------------------------------- - -Problems with CORS on the web based applications (webapp, team-settings, account-pages) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you have installed wire-server, but the web application page in your browser has connection problems and throws errors in the console such as `"Refused to connect to 'https://assets.example.com' because it violates the following Content Security Policies"`, make sure to check that you have configured the ``CSP_EXTRA_`` environment variables. - -In the file that you use as override when running ``helm install/update -f `` (using the webapp as an example): - -.. code:: yaml - - webapp: - # ... other settings... - envVars: - # ... other environment variables ... - CSP_EXTRA_CONNECT_SRC: "https://*.example.com, wss://*.example.com" - CSP_EXTRA_IMG_SRC: "https://*.example.com" - CSP_EXTRA_SCRIPT_SRC: "https://*.example.com" - CSP_EXTRA_DEFAULT_SRC: "https://*.example.com" - CSP_EXTRA_FONT_SRC: "https://*.example.com" - CSP_EXTRA_FRAME_SRC: "https://*.example.com" - CSP_EXTRA_MANIFEST_SRC: "https://*.example.com" - CSP_EXTRA_OBJECT_SRC: "https://*.example.com" - CSP_EXTRA_MEDIA_SRC: "https://*.example.com" - CSP_EXTRA_PREFETCH_SRC: "https://*.example.com" - CSP_EXTRA_STYLE_SRC: "https://*.example.com" - CSP_EXTRA_WORKER_SRC: "https://*.example.com" - -For more info, you can have a look at respective charts values files, i.e.: - - * `charts/account-pages/values.yaml `__ - * `charts/team-settings/values.yaml `__ - * `charts/webapp/values.yaml `__ - -Problems with ansible and python versions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If for instance the following fails:: - - ansible all -i hosts.ini -m shell -a "echo hello" - -If your target machine only has python 3 (not python 2.7), you can tell ansible to use python 3 by default, by specifying `ansible_python_interpreter`: - -.. code:: ini - - # hosts.ini - - [all] - server1 ansible_host=1.2.3.4 - - - [all:vars] - ansible_python_interpreter=/usr/bin/python3 - -(python 3 may not be supported by all ansible modules yet) - - -Flaky issues with Cassandra (failed QUORUMs, etc.) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Cassandra is *very* picky about time! Ensure that NTP is properly set up on all nodes. Particularly for Cassandra *DO NOT* use anything else other than ntp. Here are some helpful blogs that explain why: - - * https://blog.rapid7.com/2014/03/14/synchronizing-clocks-in-a-cassandra-cluster-pt-1-the-problem/ - * https://blog.rapid7.com/2014/03/17/synchronizing-clocks-in-a-cassandra-cluster-pt-2-solutions/ - * https://www.digitalocean.com/community/tutorials/how-to-set-up-time-synchronization-on-ubuntu-16-04 - -How can I ensure that I have correctly setup NTP on my machine(s)? Have a look at `this ansible playbook `_ - - -I deployed ``demo-smtp`` but I'm not receiving any verification emails -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -1. Check whether brig deployed successfully (brig pod(s) should be in state *Running*) :: - - kubectl get pods -o wide - -2. Inspect Brig logs :: - - kubectl logs $BRING_POD_NAME - -3. The receiving email server might refuse to accept any email sent by the `demo-smtp` server, due to not being - a trusted origin. You may want to set up one of the following email verification mechanisms. - -* `SFP `__ -* `DKIM `__ -* `DMARC `__ - - -4. You may want to adjust the SMTP configuration for Brig (``wire-server/[values,secrets].yaml``). - -.. code:: yaml - - brig: - config: - smtp: - host: 'demo-smtp' - port: 25 - connType: 'plain' - - -.. code:: yaml - - brig: - secrets: - smtpPassword: dummyPassword - -(Don't forget to apply the changes with ``helm upgrade wire-server wire/wire-server -f values.yaml -f secrets.yaml``) - -I deployed ``demo-smtp`` and I want to skip email configuration and retrieve verification codes directly -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If the only thing you need demo-smtp for is sending yourself verification codes to create a test account, it might be simpler and faster to just skip SMTP configuration, and simply retrieve the code internally right after it is sent, while it is in the outbound email queue. - -To do this, click create a user/account/team, or if you already have, click on ``Resend Code``: - -.. figure:: img/code-input.png - - The code input interface - -Then run the following command :: - - kubectl exec $(kubectl get pod -lapp=demo-smtp | grep demo | awk '{print $1;}') -- sh -c 'cat /var/spool/exim4/input/* | grep -Po "^\\d{6}$" ' - -Or step by step: - -1. Get the name of the pod :: - - kubectl get pod -lapp=demo-smtp - -Which will give you a result that looks something like this :: - - > kubectl get pod -lapp=demo-smtp - NAME READY STATUS RESTARTS AGE - demo-smtp-85557f6877-qxk2p 1/1 Running 0 80m - -In which case, the pod name is ``demo-smtp-85557f6877-qxk2p``, which replaces in the next command. - -2. Then get the content of emails and extract the code :: - - kubectl exec -- sh -c 'head -n 15 /var/spool/exim4/input/* ' - -Which will give you the content of sent emails, including the code :: - - > kubectl exec demo-smtp-85557f6877-qxk2p -- sh -c 'head -n 15 /var/spool/exim4/input/* ' - ==> /var/spool/exim4/input/1mECxm-000068-28-D <== - 1mECxm-000068-28-D - --Y3mymuwB5Y - Content-Type: text/plain; charset=utf-8 - Content-Transfer-Encoding: quoted-printable - [https://wire=2Ecom/p/img/email/logo-email-black=2Epng] - VERIFY YOUR EMAIL - myemail@gmail=2Ecom was used to register on Wire=2E Enter this code to v= - erify your email and create your account=2E - 022515 - -This means the code is ``022515``, simply enter it in the interface. - -If the email has already been sent out, it's possible the queue will be empty. - -If that is the case, simply click the "Resend Code" link in the interface, then quickly re-send the command, a new email should now be present. - -Obtaining Brig logs, and the format of different team/user events -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To obtain brig logs, simply run :: - - kubectl logs $(kubectl get pods | grep brig | awk '{print $1;}' | head -n 1) - -You will get log entries for various different types of events that happen, for example: - -1. User creation :: - - {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Creating user"]} - -2. Activation key creation ::  - - {"activation.code":"949721","activation.key":"p8o032Ljqhjgcea9R0AAnOeiUniGm63BrY9q_aeS1Cc=","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Activating"]} - -3. Activation of a new user :: - - {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","User activated"]} - -4. User indexing :: - - {"user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","logger":"index.brig","msgs":["I","Indexing user"]} - -5. Team creation ::  - - {"email_sha256":"a7ca34df62e3aa18e071e6bd4740009ce7a25278869badc1ad8f6afda792d427","team":"6ef03a2b-34b5-4b65-8d72-1e4fc7697553","user":"24bdd52e-af33-400c-8e47-d16bf8695dbd","module":"Brig.API.Public","fn":"Brig.API.Public.createUser","request":"c0575ff5a2d61bfc2be21e77260fccab","msgs":["I","Sucessfully created user"]} - -6. Invitation sent :: - - {"invitation_code":"hJuh1C1PzMkgtesAYZZ4SZrP5xO-xM_m","email_sha256":"eef48a690436699c653110387455a4afe93ce29febc348acd20f6605787956e6","team":"6ef03a2b-34b5-4b65-8d72-1e4fc7697553","module":"Brig.Team.API","fn":"Brig.Team.API.createInvitationPublic","request":"c43440074629d802a199464dd892cd92","msgs":["I","Succesfully created invitation"]} - -Diagnosing and addressing bad network/disconnect issues -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Diagnosis -========= - -If you are experiencing bad network/disconnection issues, here is how to obtain the cause from the client log files: - -In the Web client, the connection state handler logs the disconnected state as reported by WebRTC as: - -.. code:: - - flow(...): connection_handler: disconnected, starting disconnect timer - -On mobile, the output in the log is slightly different: - -.. code:: - - pf(...): ice connection state: Disconnected - -And when the timer expires and the connection is not re-established: - -.. code:: - - ecall(...): mf_restart_handler: triggering restart due to network drop - -If the attempt to reconnect then fails you will likely see the following: - -.. code:: - - ecall(...): connection timeout after 10000 milliseconds - -If the connection to the SFT (:ref:`understand-sft`) server is considered lost due to missing ping messages from a non-functionning or delayed data channel or a failure to receive/decrypt media you will see: - -.. code:: - - ccall(...): reconnect - -Then followed by these values: - -.. code:: - - cp: received CONFPART message YES/NO - da: decrypt attempted YES/NO - ds: decrypt successful YES/NO - att: number of reconnect attempts - p: the expected ping (how many pings have not returned) - -Configuration -============= - -Question: Are the connection values for bad networks/disconnect configurable on on-prem? - -Answer: The values are not currently configurable, they are built into the clients at compile time, we do have a mechanism for sending calling configs to the clients but these values are not currently there. - - - - - - diff --git a/docs/src/how-to/install/version-requirements.md b/docs/src/how-to/install/version-requirements.md new file mode 100644 index 0000000000..dc9cccc8f9 --- /dev/null +++ b/docs/src/how-to/install/version-requirements.md @@ -0,0 +1,28 @@ +# Required/Supported versions + +*Updated: 26.04.2021* + +```{warning} +If you already installed Wire by using `poetry`, please refer to the +[old version](https://docs.wire.com/versions/install-with-poetry/how-to/index.html) of +the installation guide. +``` + +## Persistence + +- Cassandra: 3.11 (OpenJDK 8) +- Elasticsearch: 6.6.0 +- Minio + : - server: latest (tested v2020-03-25) + - client: latest (tested v2020-03-14) + +### Infrastructure + +- Ubuntu: 18.04 +- Docker: latest +- Kubernetes: 1.19.7 + +### Automation + +- Ansible: 2.9 +- Helm: >= v3 diff --git a/docs/src/how-to/install/version-requirements.rst b/docs/src/how-to/install/version-requirements.rst deleted file mode 100644 index 3c204404bb..0000000000 --- a/docs/src/how-to/install/version-requirements.rst +++ /dev/null @@ -1,35 +0,0 @@ -Required/Supported versions -=========================== - -*Updated: 26.04.2021* - -.. warning:: - - If you already installed Wire by using ``poetry``, please refer to the - `old version `__ of - the installation guide. - - -Persistence -~~~~~~~~~~~ - -- Cassandra: 3.11 (OpenJDK 8) -- Elasticsearch: 6.6.0 -- Minio - - server: latest (tested v2020-03-25) - - client: latest (tested v2020-03-14) - - -Infrastructure --------------- - -- Ubuntu: 18.04 -- Docker: latest -- Kubernetes: 1.19.7 - - -Automation ----------- - -- Ansible: 2.9 -- Helm: >= v3 diff --git a/docs/src/how-to/post-install/index.rst b/docs/src/how-to/post-install/index.md similarity index 53% rename from docs/src/how-to/post-install/index.rst rename to docs/src/how-to/post-install/index.md index 4a7420aa23..2dd2009af9 100644 --- a/docs/src/how-to/post-install/index.rst +++ b/docs/src/how-to/post-install/index.md @@ -1,15 +1,15 @@ -.. _checks: +(checks)= -Verifying your wire-server installation -======================================= +# Verifying your wire-server installation After a successful installation of wire-server and its components, there are some useful checks to be run to ensure the proper functioning of the system. Here's a non-exhaustive list of checks to run on the hosts: NOTE: This page is a work in progress, more sections to be added soon. -.. toctree:: - :maxdepth: 1 - :glob: +```{toctree} +:glob: true +:maxdepth: 1 - Verifying NTP - Verifying data retention for logs don't exceed 72 hours + Verifying NTP + Verifying data retention for logs don't exceed 72 hours +``` diff --git a/docs/src/how-to/post-install/logrotation-check.md b/docs/src/how-to/post-install/logrotation-check.md new file mode 100644 index 0000000000..17bcdde7db --- /dev/null +++ b/docs/src/how-to/post-install/logrotation-check.md @@ -0,0 +1,81 @@ +(logrotation-check)= + +# Logs and Data Protection checks + +On Wire.com, we keep logs for a maximum of 72 hours as described in the [privacy whitepaper](https://wire.com/en/security/) + +We recommend you do the same and limit the amount of logs kept on your servers. + +## How can I see how far in the past access logs are still available on my servers? + +Look at the timestamps of your earliest nginz logs: + +```sh +export NAMESPACE=default # this may be 'default' or 'wire' +kubectl -n "$NAMESPACE" get pods | grep nginz +# choose one of the resulting names, it might be named e.g. nginz-6d75755c5c-h9fwn +kubectl -n "$NAMESPACE" logs -c nginz | head -10 +``` + +If the timestamp is more than 3 days in the past, your logs are kept for unnecessary long amount of time and you should configure log rotation. + +### I used your ansible scripts and prefer to have the default 72 hour maximum log availability configured automatically. + +You can use [the kubernetes_logging.yml ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/kubernetes_logging.yml) + +### I am not using ansible and like to SSH into hosts and configure things manually + +SSH into one of your kubernetes worker machines. + +If you installed as per the instructions on docs.wire.com, then the default logging strategy is `json-file` with `--log-opt max-size=50m --log-opt max-file=5` storing logs in files under `/var/lib/docker/containers//.log`. You can check this with these commands: + +```sh +docker info --format '{{.LoggingDriver}}' +ps aux | grep log-opt +``` + +(Options configured in `/etc/systemd/system/docker.service.d/docker-options.conf`) + +The default will thus keep your logs around until reaching 250 MB per pod, which is far longer than three days. Since docker logs don't allow a time-based log rotation, we can instead make use of [logrotate](https://linux.die.net/man/8/logrotate) to rotate logs for us. + +Create the file `/etc/logrotate.d/podlogs` with the following contents: + +% NOTE: in case you change these docs, also make sure to update the actual code +% under https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/kubernetes_logging.yml + +``` +"/var/lib/docker/containers/*/*.log" +{ + daily + missingok + rotate 2 + maxage 1 + copytruncate + nocreate + nocompress + } +``` + +Repeat the same for all the other kubernetes worker machines, the file needs to exist on all of them. + +There should already be a cron job for logrotate for other parts of the system, so this should be sufficent, you can stop here. + +You can check for the cron job with: + +``` +ls /etc/cron.daily/logrotate +``` + +And you can manually run a log rotation using: + +``` +/usr/sbin/logrotate -v /etc/logrotate.conf +``` + +If you want to clear out old logs entirely now, you can force log rotation three times (again, on all kubernetes machines): + +``` +/usr/sbin/logrotate -v -f /etc/logrotate.conf +/usr/sbin/logrotate -v -f /etc/logrotate.conf +/usr/sbin/logrotate -v -f /etc/logrotate.conf +``` diff --git a/docs/src/how-to/post-install/logrotation-check.rst b/docs/src/how-to/post-install/logrotation-check.rst deleted file mode 100644 index 6094d6d3a3..0000000000 --- a/docs/src/how-to/post-install/logrotation-check.rst +++ /dev/null @@ -1,79 +0,0 @@ -.. _logrotation-check: - -Logs and Data Protection checks -=============================== - -On Wire.com, we keep logs for a maximum of 72 hours as described in the `privacy whitepaper `_ - -We recommend you do the same and limit the amount of logs kept on your servers. - -How can I see how far in the past access logs are still available on my servers? --------------------------------------------------------------------------------- - -Look at the timestamps of your earliest nginz logs: - -.. code:: sh - - export NAMESPACE=default # this may be 'default' or 'wire' - kubectl -n "$NAMESPACE" get pods | grep nginz - # choose one of the resulting names, it might be named e.g. nginz-6d75755c5c-h9fwn - kubectl -n "$NAMESPACE" logs -c nginz | head -10 - -If the timestamp is more than 3 days in the past, your logs are kept for unnecessary long amount of time and you should configure log rotation. - -I used your ansible scripts and prefer to have the default 72 hour maximum log availability configured automatically. -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can use `the kubernetes_logging.yml ansible playbook `_ - -I am not using ansible and like to SSH into hosts and configure things manually -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -SSH into one of your kubernetes worker machines. - -If you installed as per the instructions on docs.wire.com, then the default logging strategy is ``json-file`` with ``--log-opt max-size=50m --log-opt max-file=5`` storing logs in files under ``/var/lib/docker/containers//.log``. You can check this with these commands: - -.. code:: sh - - docker info --format '{{.LoggingDriver}}' - ps aux | grep log-opt - -(Options configured in ``/etc/systemd/system/docker.service.d/docker-options.conf``) - -The default will thus keep your logs around until reaching 250 MB per pod, which is far longer than three days. Since docker logs don't allow a time-based log rotation, we can instead make use of `logrotate `__ to rotate logs for us. - -Create the file ``/etc/logrotate.d/podlogs`` with the following contents: - -.. - NOTE: in case you change these docs, also make sure to update the actual code - under https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/kubernetes_logging.yml -.. code:: - - "/var/lib/docker/containers/*/*.log" - { - daily - missingok - rotate 2 - maxage 1 - copytruncate - nocreate - nocompress - } - -Repeat the same for all the other kubernetes worker machines, the file needs to exist on all of them. - -There should already be a cron job for logrotate for other parts of the system, so this should be sufficent, you can stop here. - -You can check for the cron job with:: - - ls /etc/cron.daily/logrotate - -And you can manually run a log rotation using:: - - /usr/sbin/logrotate -v /etc/logrotate.conf - -If you want to clear out old logs entirely now, you can force log rotation three times (again, on all kubernetes machines):: - - /usr/sbin/logrotate -v -f /etc/logrotate.conf - /usr/sbin/logrotate -v -f /etc/logrotate.conf - /usr/sbin/logrotate -v -f /etc/logrotate.conf diff --git a/docs/src/how-to/post-install/ntp-check.md b/docs/src/how-to/post-install/ntp-check.md new file mode 100644 index 0000000000..f093998eea --- /dev/null +++ b/docs/src/how-to/post-install/ntp-check.md @@ -0,0 +1,44 @@ +(ntp-check)= + +# NTP Checks + +Ensure that NTP is properly set up on all nodes. Particularly for Cassandra **DO NOT** use anything else other than ntp. Here are some helpful blogs that explain why: + +> - +> - + +## How can I see if NTP is correctly set up? + +This is an important part of your setup, particularly for your Cassandra nodes. You should use `ntpd` and our ansible scripts to ensure it is installed correctly - but you can still check it manually if you prefer. The following 2 sub-sections explain both approaches. + +### I used your ansible scripts and prefer to have automated checks + +Then the easiest way is to use [this ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/cassandra-verify-ntp.yml) + +### I am not using ansible and like to SSH into hosts and checking things manually + +The following shows how to check for existing servers connected to (assumes `ntpq` is installed) + +```sh +ntpq -pn +``` + +which should yield something like this: + +```sh + remote refid st t when poll reach delay offset jitter +============================================================================== + time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 ++ 2 u 498 512 377 0.759 0.039 0.081 +* 2 u 412 512 377 1.251 -0.670 0.063 +``` + +if your output shows \_ONLY\_ the entry with a `.POOL.` as `refid` and a lot of 0s, something is probably wrong, i.e.: + +```sh + remote refid st t when poll reach delay offset jitter +============================================================================== + time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 +``` + +What should you do if this is the case? Ensure that `ntp` is installed and that the servers in the pool (typically at `/etc/ntp.conf`) are reachable. diff --git a/docs/src/how-to/post-install/ntp-check.rst b/docs/src/how-to/post-install/ntp-check.rst deleted file mode 100644 index 09b3852e62..0000000000 --- a/docs/src/how-to/post-install/ntp-check.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _ntp-check: - -NTP Checks -========== - -Ensure that NTP is properly set up on all nodes. Particularly for Cassandra **DO NOT** use anything else other than ntp. Here are some helpful blogs that explain why: - - * https://blog.rapid7.com/2014/03/14/synchronizing-clocks-in-a-cassandra-cluster-pt-1-the-problem/ - * https://www.digitalocean.com/community/tutorials/how-to-set-up-time-synchronization-on-ubuntu-16-04 - -How can I see if NTP is correctly set up? ------------------------------------------ - -This is an important part of your setup, particularly for your Cassandra nodes. You should use `ntpd` and our ansible scripts to ensure it is installed correctly - but you can still check it manually if you prefer. The following 2 sub-sections explain both approaches. - -I used your ansible scripts and prefer to have automated checks -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Then the easiest way is to use `this ansible playbook `_ - -I am not using ansible and like to SSH into hosts and checking things manually -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The following shows how to check for existing servers connected to (assumes `ntpq` is installed) - -.. code:: sh - - ntpq -pn - -which should yield something like this: - -.. code:: sh - - remote refid st t when poll reach delay offset jitter - ============================================================================== - time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 - + 2 u 498 512 377 0.759 0.039 0.081 - * 2 u 412 512 377 1.251 -0.670 0.063 - -if your output shows _ONLY_ the entry with a `.POOL.` as `refid` and a lot of 0s, something is probably wrong, i.e.: - -.. code:: sh - - remote refid st t when poll reach delay offset jitter - ============================================================================== - time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 - -What should you do if this is the case? Ensure that `ntp` is installed and that the servers in the pool (typically at `/etc/ntp.conf`) are reachable. diff --git a/docs/src/how-to/single-sign-on/adfs/main.md b/docs/src/how-to/single-sign-on/adfs/main.md new file mode 100644 index 0000000000..2e48fd531b --- /dev/null +++ b/docs/src/how-to/single-sign-on/adfs/main.md @@ -0,0 +1,41 @@ +# How to set up SSO integration with ADFS + +This is being used in production by some of our customers, but not +documented. We do have a few out-of-context screenshots, which we +provide here in the hope they may help. + +```{image} fig-00.jpg +``` + +```{image} fig-01.jpg +``` + +```{image} fig-02.jpg +``` + +```{image} fig-03.jpg +``` + +```{image} fig-04.jpg +``` + +```{image} fig-05.jpg +``` + +```{image} fig-06.jpg +``` + +```{image} fig-07.jpg +``` + +```{image} fig-08.jpg +``` + +```{image} fig-09.jpg +``` + +```{image} fig-10.jpg +``` + +```{image} fig-11.jpg +``` diff --git a/docs/src/how-to/single-sign-on/adfs/main.rst b/docs/src/how-to/single-sign-on/adfs/main.rst deleted file mode 100644 index 53155b14d8..0000000000 --- a/docs/src/how-to/single-sign-on/adfs/main.rst +++ /dev/null @@ -1,19 +0,0 @@ -How to set up SSO integration with ADFS -======================================= - -This is being used in production by some of our customers, but not -documented. We do have a few out-of-context screenshots, which we -provide here in the hope they may help. - -.. image:: fig-00.jpg -.. image:: fig-01.jpg -.. image:: fig-02.jpg -.. image:: fig-03.jpg -.. image:: fig-04.jpg -.. image:: fig-05.jpg -.. image:: fig-06.jpg -.. image:: fig-07.jpg -.. image:: fig-08.jpg -.. image:: fig-09.jpg -.. image:: fig-10.jpg -.. image:: fig-11.jpg diff --git a/docs/src/how-to/single-sign-on/azure/main.md b/docs/src/how-to/single-sign-on/azure/main.md new file mode 100644 index 0000000000..dbd0338907 --- /dev/null +++ b/docs/src/how-to/single-sign-on/azure/main.md @@ -0,0 +1,92 @@ +# How to set up SSO integration with Microsoft Azure + +## Preprequisites + +- account, admin access to that account +- See also {ref}`sso-generic-setup`. + +## Steps + +### Azure setup + +Go to , and click on 'Azure Active Directory' +in the menu to your left, then on 'Enterprise Applications': + +```{image} 01.png +``` + +Click on 'New Application': + +```{image} 02.png +``` + +Select 'Non-gallery application': + +```{image} 03.png +``` + +Fill in user-facing app name, then click 'add': + +```{image} 04.png +``` + +The app is now created. If you get lost, you can always get back to +it by selecting its name from the enterprise applications list you've +already visited above. + +Click on 'Configure single sign-on'. + +```{image} 05.png +``` + +Select SAML: + +```{image} 06.png +``` + +On the next page, you find a link to a configuration guide which you +can consult if you have any azure-specific questions. Or you can go +straight to adding the two config parameters you need: + +```{image} 07.png +``` + +Enter for both identity and reply url. Save. + +```{image} 08.png +``` + +Click on 'test later': + +```{image} 09.png +``` + +Finally, you need to assign users to the newly created and configured application: + +```{image} 11.png +``` + +```{image} 12.png +``` + +```{image} 13.png +``` + +```{image} 14.png +``` + +```{image} 15.png +``` + +And that's it! You are now ready to set up your wire team for SAML SSO with the XML metadata file you downloaed above. + +## Further reading + +- technical concepts overview: + : - + - +- how to create an app: + : - +- how to configure SAML2.0 SSO: + : - + - diff --git a/docs/src/how-to/single-sign-on/azure/main.rst b/docs/src/how-to/single-sign-on/azure/main.rst deleted file mode 100644 index 02115a753f..0000000000 --- a/docs/src/how-to/single-sign-on/azure/main.rst +++ /dev/null @@ -1,82 +0,0 @@ -How to set up SSO integration with Microsoft Azure -================================================== - -Preprequisites --------------- - -- http://azure.microsoft.com account, admin access to that account -- See also :ref:`SSO generic setup`. - -Steps ------ - -Azure setup -^^^^^^^^^^^ - -Go to https://portal.azure.com/, and click on 'Azure Active Directory' -in the menu to your left, then on 'Enterprise Applications': - -.. image:: 01.png - -Click on 'New Application': - -.. image:: 02.png - -Select 'Non-gallery application': - -.. image:: 03.png - -Fill in user-facing app name, then click 'add': - -.. image:: 04.png - -The app is now created. If you get lost, you can always get back to -it by selecting its name from the enterprise applications list you've -already visited above. - -Click on 'Configure single sign-on'. - -.. image:: 05.png - -Select SAML: - -.. image:: 06.png - -On the next page, you find a link to a configuration guide which you -can consult if you have any azure-specific questions. Or you can go -straight to adding the two config parameters you need: - -.. image:: 07.png - -Enter https://prod-nginz-https.wire.com/sso/finalize-login for both identity and reply url. Save. - -.. image:: 08.png - -Click on 'test later': - -.. image:: 09.png - -Finally, you need to assign users to the newly created and configured application: - -.. image:: 11.png -.. image:: 12.png -.. image:: 13.png -.. image:: 14.png -.. image:: 15.png - -And that's it! You are now ready to set up your wire team for SAML SSO with the XML metadata file you downloaed above. - - -Further reading ---------------- - -- technical concepts overview: - - https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-saml-protocol-reference - - https://docs.microsoft.com/en-us/azure/active-directory/develop/single-sign-on-saml-protocol - -- how to create an app: - - https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app - -- how to configure SAML2.0 SSO: - - https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/what-is-single-sign-on#saml-sso - - https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/configure-single-sign-on-non-gallery-applications diff --git a/docs/src/how-to/single-sign-on/centrify/main.rst b/docs/src/how-to/single-sign-on/centrify/main.md similarity index 55% rename from docs/src/how-to/single-sign-on/centrify/main.rst rename to docs/src/how-to/single-sign-on/centrify/main.md index 12d6d530fd..ed88ba6668 100644 --- a/docs/src/how-to/single-sign-on/centrify/main.rst +++ b/docs/src/how-to/single-sign-on/centrify/main.md @@ -1,46 +1,48 @@ -How to set up SSO integration with Centrify -=========================================== +# How to set up SSO integration with Centrify -Preprequisites --------------- +## Preprequisites -- http://centrify.com account, admin access to that account -- See also :ref:`SSO generic setup`. +- account, admin access to that account +- See also {ref}`sso-generic-setup`. -Steps ------ +## Steps -Centrify setup -^^^^^^^^^^^^^^ +### Centrify setup - Log in into Centrify web interface - Navigate to "Web Apps" - Click "Add Web Apps" -.. image:: 001.png +```{image} 001.png +``` ----- +______________________________________________________________________ - Create a new custom SAML application -.. image:: 002.png +```{image} 002.png +``` ----- +______________________________________________________________________ - Confirm... -.. image:: 003.png +```{image} 003.png +``` ----- +______________________________________________________________________ - Wait a few moments until the UI has rendered the `Settings` tab of your newly created Web App. - Enter at least a name, plus any other information you want to keep about this new Web App. - Then click on `Save`. -.. image:: 004.png -.. image:: 005.png +```{image} 004.png +``` ----- +```{image} 005.png +``` + +______________________________________________________________________ - Move to the `Trust` tab. This is where the SP metadata (everything centrify wants to know about wire, or Service Provider) and the IdP metadata (everything wire needs to know about centrify, or Identity Provider) can be found. - Enter `https://prod-nginz-https.wire.com/sso/finalize-login` as the SP metadata url. @@ -48,25 +50,33 @@ Centrify setup - You can see the metadata appear in the form below the `Load` button. - Click on `Save`. -.. image:: 006.png +```{image} 006.png +``` ----- +______________________________________________________________________ - Scroll down the `Trust` tab until you find the button to download the IdP metadata. - Store it in a file (eg. `my-wire-idp.xml`). You will need this file to set up your wire team for SSO. -.. image:: 007.png +```{image} 007.png +``` ----- +______________________________________________________________________ - Move to the `Permissions` tab and add at least one user. -.. image:: 008.png -.. image:: 009.png -.. image:: 010.png +```{image} 008.png +``` + +```{image} 009.png +``` + +```{image} 010.png +``` ----- +______________________________________________________________________ - If you see the status `Deployed` in the header of the `Web App` setup page, your users are ready to login. -.. image:: 011.png +```{image} 011.png +``` diff --git a/docs/src/how-to/single-sign-on/generic-setup.md b/docs/src/how-to/single-sign-on/generic-setup.md new file mode 100644 index 0000000000..d455899cca --- /dev/null +++ b/docs/src/how-to/single-sign-on/generic-setup.md @@ -0,0 +1,37 @@ +(sso-generic-setup)= + +# How to set up SSO integration with your IdP + +## Preprequisites + +- An account with your SAML IdP, admin access to that account +- Wire team, admin access to that team +- If your team is hosted at wire.com: + : - Ask customer support to enable the SSO feature flag for you. +- If you are running your own on-prem instance: + : - for handling the feature flag, you can run your own [backoffice](https://github.com/wireapp/wire-server-deploy/tree/259cd2664a4e4d890be797217cc715499d72acfc/charts/backoffice) service. + - More simply, you can configure the galley service so that sso is always enabled (just put "enabled-by-default" [here](https://github.com/wireapp/wire-server-deploy/blob/a4a35b65b2312995729b0fc2a04461508cb12de7/values/wire-server/prod-values.example.yaml#L134)). + +## Setting up your IdP + +- The SP Metadata URL: +- The SSO Login URL: +- SP Entity ID (aka Request Issuer ID): + +How you need to use this information during setting up your IdP +depends on the vendor. Let us know if you run into any trouble! + +## Setting up your wire team + +See + +## Authentication + +The team settings will show you a login code from us that looks like +eg. + +\> `wire-959b5840-3e8a-11e9-adff-0fa5314b31c0` + +See +- +on how to use this to login on wire. diff --git a/docs/src/how-to/single-sign-on/generic-setup.rst b/docs/src/how-to/single-sign-on/generic-setup.rst deleted file mode 100644 index 79f4d9585a..0000000000 --- a/docs/src/how-to/single-sign-on/generic-setup.rst +++ /dev/null @@ -1,42 +0,0 @@ -.. _SSO generic setup: - -How to set up SSO integration with your IdP -=========================================== - -Preprequisites --------------- - -- An account with your SAML IdP, admin access to that account -- Wire team, admin access to that team -- If your team is hosted at wire.com: - - Ask customer support to enable the SSO feature flag for you. -- If you are running your own on-prem instance: - - for handling the feature flag, you can run your own `backoffice `_ service. - - More simply, you can configure the galley service so that sso is always enabled (just put "enabled-by-default" `here `_). - -Setting up your IdP -------------------- - -- The SP Metadata URL: https://prod-nginz-https.wire.com/sso/metadata -- The SSO Login URL: https://prod-nginz-https.wire.com/sso/finalize-login -- SP Entity ID (aka Request Issuer ID): https://prod-nginz-https.wire.com/sso/finalize-login - -How you need to use this information during setting up your IdP -depends on the vendor. Let us know if you run into any trouble! - -Setting up your wire team -------------------------- - -See https://support.wire.com/hc/en-us/articles/360001285638-Set-up-SSO-internally - -Authentication --------------- - -The team settings will show you a login code from us that looks like -eg. - -> `wire-959b5840-3e8a-11e9-adff-0fa5314b31c0` - -See -https://support.wire.com/hc/en-us/articles/360000954617-Pro-How-to-log-in-with-SSO- -on how to use this to login on wire. diff --git a/docs/src/how-to/single-sign-on/index.md b/docs/src/how-to/single-sign-on/index.md new file mode 100644 index 0000000000..2cdb939676 --- /dev/null +++ b/docs/src/how-to/single-sign-on/index.md @@ -0,0 +1,15 @@ +# Single Sign-On and User Provisioning + +```{toctree} +:caption: 'Contents:' +:glob: true +:maxdepth: 1 + +Single sign-on and user provisioning +Generic setup +SSO integration with ADFS +SSO integration with Azure +SSO integration with Centrify +SSO integration with Okta +* +``` diff --git a/docs/src/how-to/single-sign-on/okta/main.rst b/docs/src/how-to/single-sign-on/okta/main.md similarity index 73% rename from docs/src/how-to/single-sign-on/okta/main.rst rename to docs/src/how-to/single-sign-on/okta/main.md index faf3799db0..6fe285c55f 100644 --- a/docs/src/how-to/single-sign-on/okta/main.rst +++ b/docs/src/how-to/single-sign-on/okta/main.md @@ -1,53 +1,56 @@ -How to set up SSO integration with Okta -======================================= +(sso-int-with-okta)= -Preprequisites --------------- +# How to set up SSO integration with Okta -- http://okta.com/ account, admin access to that account -- See also :ref:`SSO generic setup`. +## Preprequisites -Steps ------ +- account, admin access to that account +- See also {ref}`sso-generic-setup`. -Okta setup -~~~~~~~~~~ +## Steps + +### Okta setup - Log in into Okta web interface - Open the admin console and switch to the "Classic UI" - Navigate to "Applications" - Click "Add application" -.. image:: 001-applications-screen.png +```{image} 001-applications-screen.png +``` ----- +______________________________________________________________________ - Create a new application -.. image:: 002-add-application.png +```{image} 002-add-application.png +``` ----- +______________________________________________________________________ - Choose `Web`, `SAML 2.0` -.. image:: 003-add-application-1.png +```{image} 003-add-application-1.png +``` ----- +______________________________________________________________________ - Pick a name for the application in "Step 1" and continue -.. image:: 004-add-application-step1.png +```{image} 004-add-application-step1.png +``` ----- +______________________________________________________________________ - Add the following parameters in "Step 2" and continue +```{eval-rst} +-----------------------------+------------------------------------------------------------------------------+ + Paramenter label | Value | +=============================+==============================================================================+ | Single Sign On URL | `https://prod-nginz-https.wire.com/sso/finalize-login` | +-----------------------------+------------------------------------------------------------------------------+ -| Use this for Recipient URL | checked ✅ | +| Use this for Recipient URL | checked | | and Destination URL | | +-----------------------------+------------------------------------------------------------------------------+ | Audience URI (SP Entity ID) | `https://prod-nginz-https.wire.com/sso/finalize-login` | @@ -56,34 +59,41 @@ Okta setup +-----------------------------+------------------------------------------------------------------------------+ | Application Username | `Email` (\*) | +-----------------------------+------------------------------------------------------------------------------+ +``` **(\*) Note**: The application username **must be** unique in your team, and should be immutable once assigned. If more than one user has the same value for the field that you select here, those two users will log in as a single user on Wire. And if the value were to change, users will be re-assigned to a new account at the next login. Usually, `email` is a safe choice but you should evaluate it for your case. -.. image:: 005-add-application-step2.png +```{image} 005-add-application-step2.png +``` ----- +______________________________________________________________________ - Give the following answer in "Step 3" and continue +```{eval-rst} +-----------------------------------+------------------------------------------------------------------------+ + Paramenter label | Value | +===================================+========================================================================+ | Are you a customer or a partner? | I'm an Okta customer | +-----------------------------------+------------------------------------------------------------------------+ +``` -.. image:: 006-add-application-step3.png +```{image} 006-add-application-step3.png +``` ----- +______________________________________________________________________ - The app has been created. Switch to the "Sign-On" tab - Find the "Identity Provider Metadata" link. Copy the link address (normally done by right-clicking on the link and selecting "Copy link location" or a similar item in the menu). - Store the link address somewhere for a future step. -.. image:: 007-application-sign-on.png +```{image} 007-application-sign-on.png +``` ----- +______________________________________________________________________ - Switch to the "Assignments" tab - Make sure that some users (or everyone) is assigned to the application. These are the users that will be allowed to log in to Wire using Single Sign On. Add the relevant users to the list with the "Assign" button. -.. image:: 008-assignment.png +```{image} 008-assignment.png +``` diff --git a/docs/src/how-to/single-sign-on/trouble-shooting.rst b/docs/src/how-to/single-sign-on/trouble-shooting.md similarity index 60% rename from docs/src/how-to/single-sign-on/trouble-shooting.rst rename to docs/src/how-to/single-sign-on/trouble-shooting.md index da0ca43210..cdc7e1204a 100644 --- a/docs/src/how-to/single-sign-on/trouble-shooting.rst +++ b/docs/src/how-to/single-sign-on/trouble-shooting.md @@ -1,32 +1,28 @@ -.. _trouble-shooting-faq: +(trouble-shooting-faq)= -Trouble shooting & FAQ -====================== +# Trouble shooting & FAQ -Reporting a problem with user provisioning or SSO authentication ----------------------------------------------------------------- +## Reporting a problem with user provisioning or SSO authentication In order for us to analyse and understand your problem, we need at least the following information up-front: - Have you followed the following instructions? - - :ref:`FAQ ` (This document) - - `Howtos `_ for supported vendors - - `General documentation on the setup flow `_ + : - {ref}`FAQ ` (This document) + - [Howtos](https://docs.wire.com/how-to/single-sign-on/index.html) for supported vendors + - [General documentation on the setup flow](https://support.wire.com/hc/en-us/articles/360001285718-Set-up-SSO-externally) - Vendor information (octa, azure, centrica, other (which one)?) - Team ID (looks like eg. `2e9a9c9c-6f83-11eb-a118-3342c6f16f4e`, can be found in team settings) - What do you expect to happen? - - eg.: "I enter login code, authenticate successfully against IdP, get redirected, and see the wire landing page." + : - eg.: "I enter login code, authenticate successfully against IdP, get redirected, and see the wire landing page." - What does happen instead? - - Screenshots + : - Screenshots - Copy the text into your report where applicable in addition to screenshots (for automatic processing). - eg.: "instead of being logged into wire, I see the following error page: ..." - Screenshots of the Configuration (both SAML and SCIM, as applicable), including, but not limited to: - - If you are using SAML: SAML IdP metadata file + : - If you are using SAML: SAML IdP metadata file - If you are using SCIM for provisioning: Which attributes in the User schema are mapped? How? - -Can I use the same SSO login code for multiple teams? ------------------------------------------------------ +## Can I use the same SSO login code for multiple teams? No, but there is a good reason for it and a work-around. @@ -45,28 +41,21 @@ still use the same user base for all teams. This has the extra advantage that a user can be part of two teams with the same credentials, which would be impossible even with the hypothetical fix. - -Can an existing user without IdP (or with a different IdP) be bound to a new IdP? ---------------------------------------------------------------------------------- +## Can an existing user without IdP (or with a different IdP) be bound to a new IdP? No. This is a feature we never fully implemented. Details / latest -updates: https://github.com/wireapp/wire-server/issues/1151 - - -Can the SSO feature be disabled for a team? -------------------------------------------- +updates: -No, this is `not implemented `_. +## Can the SSO feature be disabled for a team? +No, this is [not implemented](https://github.com/wireapp/wire-server/blob/7a97cb5a944ae593c729341b6f28dfa1dabc28e5/services/galley/src/Galley/API/Error.hs#L215). -Can you remove a SAML connection? ---------------------------------- +## Can you remove a SAML connection? It is not possible to delete a SAML connection in the Team Settings app, however it can be overwritten with a new connection. -It is possible do delete a SAML connection directly via the API endpoint ``DELETE /identity-providers/{id}``. However deleting a SAML connection also requires deleting all users that can log in with this SAML connection. To prevent accidental deletion of users this functionality is not available directly from Team Settings. +It is possible do delete a SAML connection directly via the API endpoint `DELETE /identity-providers/{id}`. However deleting a SAML connection also requires deleting all users that can log in with this SAML connection. To prevent accidental deletion of users this functionality is not available directly from Team Settings. -If you get an error when returning from your IdP ------------------------------------------------- +## If you get an error when returning from your IdP `Symptoms:` @@ -86,9 +75,7 @@ that contains a lot of machine-readable info. With all this information, please get in touch with our customer support. - -Do I need any firewall settings? --------------------------------- +## Do I need any firewall settings? No. @@ -96,9 +83,7 @@ There is nothing to be done here. There is no internet traffic between your SAML IdP and the wire service. All communication happens via the browser or app. - -Why does the team owner have to keep using password? ----------------------------------------------------- +## Why does the team owner have to keep using password? The user who creates the team cannot be authenticated via SSO. There is fundamentally no easy way around that: we need somebody to give us @@ -119,71 +104,66 @@ for IdP registration and upgrade of IdP-authenticated owners / admins. In practice, user A and some owner authenticated via IdP would then be controlled by the same person, probably. - -What should the SAML response look like? ----------------------------------------- +## What should the SAML response look like? Here is an example that works. Much of this beyond the subject's NameID is required by the SAML standard. If you can find a more minimal example that still works, we'd be love to take a look. -.. code:: xml - - - ... - - - - - - - - - - - - - ... - - - ... - - - ... - - - - - ... - - - - - - - https://prod-nginz-https.wire.com/sso/finalize-login - - - - - - urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport - - - - - -Why does the auth response not contain a reference to an auth request? (Also: can i use IdP-initiated login?) ------------------------------------------------------------------------------------------------------------------ +```xml + + ... + + + + + + + + + + + + + ... + + + ... + + + ... + + + + + ... + + + + + + + https://prod-nginz-https.wire.com/sso/finalize-login + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + +``` + +## Why does the auth response not contain a reference to an auth request? (Also: can i use IdP-initiated login?) tl;dr: Wire only supports SP-initiated login, where the user selects the auth method from inside the app's login screen. It does not support IdP-initiated login, where the user enters the app from a list of applications in the IdP UI. -The full story -^^^^^^^^^^^^^^ +### The full story SAML authentication can be initiated by the IdP (eg., Okta or Azure), or by the SP (Wire). @@ -206,9 +186,7 @@ impersonate rogue accounts) hard that were otherwise quite feasible. Wire therefore only supports SP-initiated login. - -How are SAML2 assertion details used in wire? ---------------------------------------------- +## How are SAML2 assertion details used in wire? Wire only uses the SAML `NameID` from the assertion, plus the information whether authentication and authorization was successful. @@ -221,9 +199,7 @@ wire user display name a default value. (The user will be allowed to change that value later; changing it does NOT affect the authentication handshake between wire and the IdP.) - -How should I map user data to SCIM attributes when provisioning users via SCIM? -------------------------------------------------------------------------------- +## How should I map user data to SCIM attributes when provisioning users via SCIM? If you are provisioning users via SCIM, the following mapping is used in your wire team: @@ -238,17 +214,16 @@ in your wire team: 3. SCIM's `preferredLanguage` is mapped to wire's user locale settings when a locale is not defined for that user. It must consist of an - ISO 639-1 language code. + ISO 639-1 language code. 4. SCIM's `externalId`: - a. If SAML SSO is used, it is mapped on the SAML `NameID`. If it + 1. If SAML SSO is used, it is mapped on the SAML `NameID`. If it parses as an email, it will have format `email`, and you can choose to validate it during provisioning (by enabeling the feature flag for your team). Otherwise, the format will be `unspecified`. - - b. If email/password authentication is used, SCIM's `externalId` is + 2. If email/password authentication is used, SCIM's `externalId` is mapped on wire's email address, and provisioning works like in team settings with invitation emails. @@ -262,29 +237,24 @@ Also note that the account will be set to `"active": false` until the user has accepted the invitation and activated the account. Please contact customer support if this causes any issues. - -Can I distribute a URL to my users that contains the login code? ----------------------------------------------------------------- +## Can I distribute a URL to my users that contains the login code? Users may find it awkward to copy and paste the login code into the form. If they are using the webapp, an alternative is to give them the following URL (fill in the login code that you can find in your team settings): -.. code:: bash +```bash +https://wire-webapp-dev.zinfra.io/auth#sso/3c4f050a-f073-11eb-b4c9-931bceeed13e +``` - https://wire-webapp-dev.zinfra.io/auth#sso/3c4f050a-f073-11eb-b4c9-931bceeed13e - - -(Theoretical) name clashes in SAML NameIDs ------------------------------------------- +## (Theoretical) name clashes in SAML NameIDs You can technically configure your SAML IdP to create name clashes in wire, ie., to map two (technically) different NameIDs to the same wire user. -How to know you're safe -^^^^^^^^^^^^^^^^^^^^^^^ +### How to know you're safe This is highly unlikely, since the distinguishing parts of `NameID` that we ignore are generally either @@ -292,16 +262,14 @@ unused or redundant. If you are confident that any two users you have assigned to the wire app can be distinguished solely by the lower-cased `NameID` content, you're safe. -Impact -^^^^^^ +### Impact If you are using SCIM for user provisioning, this may lead to errors during provisioning of new users ("user already exists"). If you use SAML auto-provisioning, this may lead to unintential account sharing instead of an error. -How to reproduce -^^^^^^^^^^^^^^^^ +### How to reproduce If you have users whose combination of `IssuerId` and `NameID` can only be distinguished by casing (upper @@ -309,30 +277,27 @@ vs. lower) or by the `NameID` qualifiers (`NameID` xml attributes `NameQualifier`, `IdPNameQualifier`, ...), those users will name clash. -Solution -^^^^^^^^ +### Solution Do not rely on case sensitivity of `IssuerID` or `NameID`, or on `NameID` qualifiers for distinguishing user identifiers. - -How to report problems ----------------------- +## How to report problems If you have a problem you cannot resolve by yourself, please get in touch. Add as much of the following details to your report as possible: -* Are you on cloud or on-prem? (If on-prem: which instance?) -* XML IdP metadata -* SSL Login code or IdP Issuer EntityID -* NameID of the account that has the problem -* SP metadata +- Are you on cloud or on-prem? (If on-prem: which instance?) +- XML IdP metadata +- SSL Login code or IdP Issuer EntityID +- NameID of the account that has the problem +- SP metadata Problem description, including, but not limited to: -* what happened? -* what did you want to happen? -* what does your idp config in the wire team management app look like? -* what does your wire config in your IdP management app look like? -* Please include screenshots *and* copied text (for cut&paste when we investigate) *and* further description and comments where feasible. +- what happened? +- what did you want to happen? +- what does your idp config in the wire team management app look like? +- what does your wire config in your IdP management app look like? +- Please include screenshots *and* copied text (for cut&paste when we investigate) *and* further description and comments where feasible. (If you can't produce some of this information of course please get in touch anyway! It'll merely be harder for us to resolve your issue quickly, and we may need to make a few extra rounds of data gathering together with you.) diff --git a/docs/src/understand/single-sign-on/Wire_SAML_Flow (lucidchart).svg b/docs/src/how-to/single-sign-on/understand/Wire_SAML_Flow (lucidchart).svg similarity index 100% rename from docs/src/understand/single-sign-on/Wire_SAML_Flow (lucidchart).svg rename to docs/src/how-to/single-sign-on/understand/Wire_SAML_Flow (lucidchart).svg diff --git a/docs/src/understand/single-sign-on/Wire_SAML_Flow.png b/docs/src/how-to/single-sign-on/understand/Wire_SAML_Flow.png similarity index 100% rename from docs/src/understand/single-sign-on/Wire_SAML_Flow.png rename to docs/src/how-to/single-sign-on/understand/Wire_SAML_Flow.png diff --git a/docs/src/how-to/single-sign-on/understand/main.md b/docs/src/how-to/single-sign-on/understand/main.md new file mode 100644 index 0000000000..465ef9dc48 --- /dev/null +++ b/docs/src/how-to/single-sign-on/understand/main.md @@ -0,0 +1,561 @@ +# Single sign-on and user provisioning + +```{contents} +``` + +## Introduction + +This page is intended as a manual for administrator users in need of setting up {term}`SSO` and provisionning users using {term}`SCIM` on their installation of Wire. + +Historically and by default, Wire's user authentication method is via phone or password. This has security implications and does not scale. + +Solution: {term}`SSO` with {term}`SAML`! [(Security Assertion Markup Language)](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) + +{term}`SSO` systems allow users to identify on multiple systems (including Wire once configured as such) using a single ID and password. + +You can find some of the advantages of {term}`SSO` over more traditional schemes [here](https://en.wikipedia.org/wiki/Single_sign-on). + +Also historically, wire has allowed team admins and owners to manage their users in the team management app. + +This does not scale as it requires a lot of manual labor for each user. + +The solution we offer to solve this issue is implementing {term}`SCIM` [(System for Cross-domain Identity Management)](https://en.wikipedia.org/wiki/System_for_Cross-domain_Identity_Management) + +{term}`SCIM` is an interface that allows both software (for example Active Directory) and custom scripts to manage Identities (users) in bulk. + +This page explains how to set up {term}`SCIM` and then use it. + +```{note} +Note that it is recommended to use both {term}`SSO` and {term}`SCIM` (as opposed to just {term}`SSO` alone). +The reason is if you only use {term}`SSO`, but do not configure/implement {term}`SCIM`, you will experience reduced functionality. +In particular, without {term}`SCIM` all Wire users will be named according their e-mail address and won't have any rich profiles. +See below in the {term}`SCIM` section for a more detailled explanation. +``` + +## Further reading + +If you can't find the answers to your questions here, we have a few +more documents. Some of them are very technical, some may not be up +to date any more, and we are planning to move many of them into this +page. But for now they may be worth checking out. + +- {ref}`Trouble shooting & FAQ ` +- +- +- + +## Definitions + +The following concepts need to be understood to use the present manual: + +```{eval-rst} +.. glossary:: + + SCIM + System for Cross-domain Identity Management (:term:`SCIM`) is a standard for automating the exchange of user identity information between identity domains, or IT systems. + + One example might be that as a company onboards new employees and separates from existing employees, they are added and removed from the company's electronic employee directory. :term:`SCIM` could be used to automatically add/delete (or, provision/de-provision) accounts for those users in external systems such as G Suite, Office 365, or Salesforce.com. Then, a new user account would exist in the external systems for each new employee, and the user accounts for former employees might no longer exist in those systems. + + See: `System for Cross-domain Identity Management at Wikipedia `_ + + In the context of Wire, SCIM is the interface offered by the Wire service (in particular the spar service) that allows for single or mass automated addition/removal of user accounts. + + SSO + + Single sign-on (:term:`SSO`) is an authentication scheme that allows a user to log in with a single ID and password to any of several organizationally related, yet independent, software systems. + + True single sign-on allows the user to log in once and access different, independent services without re-entering authentication factors. + + See: `Single-Sign-On at Wikipedia `_ + + SAML + + Security Assertion Markup Language (:term:`SAML`, pronounced SAM-el, /'sæməl/) is an open standard for exchanging authentication and authorization data between parties, in particular, between an identity provider and a service provider. :term:`SAML` is an XML-based markup language for security assertions (statements that service providers use to make access-control decisions). :term:`SAML` is also: + + * A set of XML-based protocol messages + * A set of protocol message bindings + * A set of profiles (utilizing all of the above) + + An important use case that :term:`SAML` addresses is web-browser `single sign-on (SSO) `_ . Single sign-on is relatively easy to accomplish within a security domain (using cookies, for example) but extending :term:`SSO` across security domains is more difficult and resulted in the proliferation of non-interoperable proprietary technologies. The `SAML Web Browser SSO `_ profile was specified and standardized to promote interoperability. + + See: `SAML at Wikipedia `_ + + In the context of Wire, SAML is the standard/protocol used by the Wire services (in particular the spar service) to provide the Single Sign On feature. + + IdP + + In the context of Wire, an identity provider (abbreviated :term:`IdP`) is a service that provides SAML single sign-on (:term:`SSO`) credentials that give users access to Wire. + + Curl + + :term:`Curl` (pronounced ":term:`Curl`") is a command line tool used to download files over the HTTP (web) protocol. For example, `curl http://wire.com` will download the ``wire.com`` web page. + + In this manual, it is used to contact API (Application Programming Interface) endpoints manually, where those endpoints would normally be accessed by code or other software. + + This can be used either for illustrative purposes (to "show" how the endpoints can be used) or to allow the manual execution of some simple tasks. + + For example (not a real endpoint) `curl http://api.wire.com/delete_user/thomas` would (schematically) execute the :term:`Curl` command, which would contact the wire.com API and delete the user named "thomas". + + Running this command in a terminal would cause the :term:`Curl` command to access this URL, and the API at that URL would execute the requested action. + + See: `curl at Wikipedia `__ + + + Spar + + The Wire backend software stack is composed of different services, `running as pods <../overview.html#focus-on-pods>`__ in a kubernetes cluster. + + One of those pods is the "spar" service. That service/pod is dedicated to the providing :term:`SSO` (using :term:`SAML`) and :term:`SCIM` services. This page is the manual for this service. + + In the context of :term:`SCIM`, Wire's spar service is the `Service Provider `__ that Identity Management Software + (for example Azure, Okta, Ping Identity, SailPoint, Technology Nexus, etc.) uses for user account provisioning and deprovisioning. +``` + +## User login for the first time with SSO + +{term}`SSO` allows users to register and log into Wire with their company credentials that they use on other software in their workplace. +No need to remember another password. + +When a team is set up on Wire, the administrators can provide users a login code or link that they can use to go straight to their company's login page. + +Here is what this looks from a user's perspective: + +1. Download Wire. +2. Select and copy the code that your company gave you / the administrator generated +3. Open Wire. Wire may detect the code on your clipboard and open a pop-up window with a text field. + Wire will automatically put the code into the text field. + If so, click Log in and go to step 8. +4. If no pop-up: click Login on the first screen. +5. Click Enterprise Login. +6. A pop-up will appear. In the text field, paste or type the code your company gave you. +7. Click Log in. +8. Wire will load your company's login page: log in with your company credentials. + +(saml-sso)= + +## SAML/SSO + +### Introduction + +SSO (Single Sign-On) is technology allowing users to sign into multiple services with a single identity provider/credential. + +SSO is about `authentication`, not `provisioning` (create, update, remove user accounts). To learn more about the latter, continue {ref}`below `. + +For example, if a company already has SSO setup for some of their services, and they start using Wire, they can use Wire's SSO support to add Wire to the set of services their users will be able to sign into with their existing SSO credentials. + +Here is a blog post we like about how SAML works: + +And here is a diagram that explains it in slightly more technical terms: + +```{image} Wire_SAML_Flow.png +``` + +Here is a critique of XML/DSig security (which SAML relies on): + +### Terminology and concepts + +- End + The browser carrries out all the redirections from the SP to the IdP and vice versa. +- Service Provider (SP): The entity (here Wire software) that provides its protected resource when an end user tries to access this resource. To accomplish the SAML based SSO authentication, the Service Provider + must have the Identity Provider's metadata. +- Identity Provider (IdP): Defines the entity that provides the user identities, including the ability to authenticate a user to get access to a protected resource / application from a Service Provider. To accomplish + the SAML based SSO authentication, the IdP must have the Service Provider's metadata. +- SAML Request: This is the authentication request generated by the Service Provider to request an authentication from the Identity Provider for verifying the user's identity. +- SAML Response: The SAML Response contains the cryptographically signed assertion of the authenticated user and is generated by the Identity Provider. + +(Definitons adapted from [collab.net](http://help.collab.net/index.jsp?topic=/teamforge178/action/saml.html)) + +(setting-up-sso-externally)= + +### Setting up SSO externally + +To set up {term}`SSO` for a given Wire installation, the Team owner/administrator must enable it. + +The first step is to configure the Identity Provider: you'll need to register Wire as a service provider in your Identity Provider. + +We've put together guides for registering with different providers: + +- Instructions for {ref}`Okta ` +- Instructions for {doc}`Centrify <../centrify/main>` +- Instructions for {doc}`Azure <../azure/main>` +- Some screenshots for {doc}`ADFS <../adfs/main>` +- {doc}`Generic instructions (try this if none of the above are applicable) <../generic-setup>` + +As you do this, make sure you take note of your {term}`IdP` metadata, which you will need for the next step. + +Once you are finished with registering Wire to your {term}`IdP`, move on to the next step, setting up {term}`SSO` internally. + +### Setting up SSO internally + +Now that you've registered Wire with your identity provider ({term}`IdP`), you can enable {term}`SSO` for your team on Wire. + +On Desktop: + +- Click Settings and click "Manage Team"; or go directly to teams.wire.com, or if you have an on-premise install, go to teams.\.com +- Login with your account credentials. +- Click "Customization". Here you will see the section for {term}`SSO`. +- Click the blue down arrow. +- Click "Add {term}`SAML` Connection". +- Provide the {term}`IdP` metadata. To find out more about retrieving this for your provider, see the guides in the "Setting up {term}`SSO` externally" step just above. +- Click "Save". +- Wire will now validate the document to set up the {term}`SAML` connection. +- If the data is valid, you will return to the Settings page. +- The page shows the information you need to log in with {term}`SSO`. Copy the login code or URL and send it to your team members or partners. For more information see: Logging in with {term}`SSO`. + +What to expect after {term}`SSO` is enabled: + +Anyone with a login through your {term}`SAML` identity provider ({term}`IdP`) and with access to the Wire app will be able to register and log in to your team using the {term}`SSO` Login URL and/or Code. + +Take care to share the code only with members of your team. + +If you haven't set up {term}`SCIM` ([we recommend you do](#introduction)), your team members can create accounts on Wire using {term}`SSO` simply by logging in, and will appear on the People tab of the team management page. + +If team members already have Wire accounts, use {term}`SCIM` to associate them with the {term}`SAML` credentials. If you make a mistake here, you may end up with several accounts for the same person. + +(user-provisioning-scim-ldap)= + +## User provisioning (SCIM/LDAP) + +SCIM/LDAP is about `provisioning` (create, update, remove user accounts), not `authentication`. To learn more about the latter, continue {ref}`above `. + +Wire supports the [SCIM](http://www.simplecloud.info/) ([RFC 7643](https://tools.ietf.org/html/rfc7643)) protocol to create, update and delete users. + +If your user data is stored in an LDAP data source like Active Directory or OpenLDAP, you can use our docker-base [ldap-scim-bridge](https://github.com/wireapp/ldap-scim-bridge/#use-via-docker) to connect it to wire. + +Note that connecting a SCIM client to Wire also disables the functionality to create new users in the SSO login process. This functionality is disabled when a token is created (see below) and re-enabled when all tokens have been deleted. + +To set up the connection of your SCIM client (e.g. Azure Active Directory) you need to provide + +1. The URL under which Wire's SCIM API is hosted: `https://prod-nginz-https.wire.com/scim/v2`. + If you are hosting your own instance of Wire then the URL is `https:///scim/v2`, where `` is where you are serving Wire's public endpoints. Some SCIM clients append `/v2` to the URL your provide. If this happens (check the URL mentioned in error messages of your SCIM client) then please provide the URL without the `/v2` suffix, i.e. `https://prod-nginz-https.wire.com/scim` or `https:///scim`. +2. A secret token which authorizes the use of the SCIM API. Use the [wire_scim_token.py](https://raw.githubusercontent.com/wireapp/wire-server/654b62e3be74d9dddae479178990ebbd4bc77b1e/docs/reference/provisioning/wire_scim_token.py) + script to generate a token. To run the script you need access to an user account with "admin" privileges that can login via email and password. Note that the token is independent from the admin account that created it, i.e. the token remains valid if the admin account gets deleted or changed. + +You need to configure your SCIM client to use the following mandatory SCIM attributes: + +1. Set the `userName` attribute to the desired user handle (the handle is shown + with an @ prefix in apps). It must be unique accross the entire Wire Cloud + (or unique on your own instance), and consist of the characters `a-z0-9_.-` + (no capital letters). + +2. Set the `displayName` attribute to the user's desired display name, e.g. "Jane Doe". + It must consist of 1-128 unicode characters. It does not need to be unique. + +3. The `externalId` attribute: + + 1. If you are using Wire's SAML SSO feature then set `externalId` attribute to the same identifier used for `NameID` in your SAML configuration. + 2. If you are using email/password authentication then set the `externalId` + attribute to the user's email address. The user will receive an invitation email during provisioning. Also note that the account will be set to `"active": false` until the user has accepted the invitation and activated the account. + +You can optionally make use of Wire's `urn:wire:scim:schemas:profile:1.0` extension field to store arbitrary user profile data that is shown in the users profile, e.g. department, role. See [docs](https://github.com/wireapp/wire-server/blob/develop/docs/reference/user/rich-info.md#scim-support-refrichinfoscim) for details. + +### SCIM management in Wire (in Team Management) + +#### SCIM security and authentication + +Wire uses a very basic variant of oauth, where a *bearer token* is presented to the server in header with all {term}`SCIM` requests. + +You can create such bearer tokens in team management and copy them from there into your the dashboard of your SCIM data source. + +#### Generating a SCIM token + +In order to be able to send SCIM requests to Wire, we first need to generate a SCIM token. This section explains how to do this. + +Once the token is generated, it should be noted/remembered, and it will be used in all subsequent SCIM uses/requests to authenticate the request as valid/authenticated. + +These are the steps to generate a new {term}`SCIM` token, which you will need to provide to your identity provider ({term}`IdP`), along with the target API URL, to enable {term}`SCIM` provisionning. + +- Step 1: Go to (Here replace "wire.com" with your own domain if you have an on-premise installation of Wire). + +```{image} token-step-01.png +:align: center +``` + +- Step 2: In the left menu, go to "Customization". + +```{image} token-step-02.png +:align: center +``` + +- Step 3: Go to "Automated User Management ({term}`SCIM`)" and click the "down" to expand + +```{image} token-step-03.png +:align: center +``` + +- Step 4: Click "Generate token", if your password is requested, enter it. + +```{image} token-step-04.png +:align: center +``` + +- Step 5: Once the token is generated, copy it into your clipboard and store it somewhere safe (eg., in the dashboard of your SCIM data source). + +```{image} token-step-05.png +:align: center +``` + +- Step 6: You're done! You can now view token information, delete the token, or create more tokens should you need them. + +```{image} token-step-06.png +:align: center +``` + +Tokens are now listed in this {term}`SCIM`-related area of the screen, you can generate up to 8 such tokens. + +### Using SCIM via Curl + +You can use the term:`Curl` command line HTTP tool to access tho wire backend (in particular the `spar` service) through the {term}`SCIM` API. + +This can be helpful to write your own tooling to interface with wire. + +#### Creating a SCIM token + +Before we can send commands to the {term}`SCIM` API/Spar service, we need to be authenticated. This is done through the creation of a {term}`SCIM` token. + +First, we need a little shell environment. Run the following in your terminal/shell: + +```{code-block} bash +:linenos: true + + export WIRE_BACKEND=https://prod-nginz-https.wire.com + export WIRE_ADMIN=... + export WIRE_PASSWD=... +``` + +Wire's SCIM API currently supports a variant of HTTP basic auth. + +In order to create a token in your team, you need to authenticate using your team admin credentials. + +The way this works behind the scenes in your browser or cell phone, and in plain sight if you want to use curl, is you need to get a Wire token. + +First install the `jq` command (): + +```bash +sudo apt install jq +``` + +```{note} +If you don't want to install `jq`, you can just call the `curl` command and copy the access token into the shell variable manually. +``` + +Then run: + +```{code-block} bash +:linenos: true + +export BEARER=$(curl -X POST \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +-d '{"email":"'"$WIRE_ADMIN"'","password":"'"$WIRE_PASSWD"'"}' \ +$WIRE_BACKEND/login'?persist=false' | jq -r .access_token) +``` + +This token will be good for 15 minutes; after that, just repeat the command above to get a new token. + +```{note} +SCIM requests are authenticated with a SCIM token, see below. SCIM tokens and Wire tokens are different things. + +A Wire token is necessary to get a SCIM token. SCIM tokens do not expire, but need to be deleted explicitly. +``` + +You can test that you are logged in with the following command: + +```bash +curl -X GET --header "Authorization: Bearer $BEARER" $WIRE_BACKEND/self +``` + +Now you are ready to create a SCIM token: + +```{code-block} bash +:linenos: true + +export SCIM_TOKEN_FULL=$(curl -X POST \ +--header "Authorization: Bearer $BEARER" \ +--header 'Content-Type: application/json;charset=utf-8' \ +-d '{ "description": "test '"`date`"'", "password": "'"$WIRE_PASSWD"'" }' \ +$WIRE_BACKEND/scim/auth-tokens) +export SCIM_TOKEN=$(echo $SCIM_TOKEN_FULL | jq -r .token) +export SCIM_TOKEN_ID=$(echo $SCIM_TOKEN_FULL | jq -r .info.id) +``` + +The SCIM token is now contained in the `SCIM_TOKEN` environment variable. + +You can look it up again with: + +```{code-block} bash +:linenos: true + +curl -X GET --header "Authorization: Bearer $BEARER" \ +$WIRE_BACKEND/scim/auth-tokens +``` + +And you can delete it with: + +```{code-block} bash +:linenos: true + +curl -X DELETE --header "Authorization: Bearer $BEARER" \ +$WIRE_BACKEND/scim/auth-tokens?id=$SCIM_TOKEN_ID +``` + +#### Using a SCIM token to Create Read Update and Delete (CRUD) users + +Now that you have your SCIM token, you can use it to talk to the SCIM API to manipulate (create, read, update, delete) users, either individually or in bulk. + +**JSON encoding of SCIM Users** + +In order to manipulate users using commands, you need to specify user data. + +A minimal definition of a user is written in JSON format and looks like this: + +```{code-block} json +:linenos: true + +{ + "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" : "nick@example.com", + "userName" : "nick", + "displayName" : "The Nick" +} +``` + +You can store it in a variable using this sort of command: + +```{code-block} bash +:linenos: true + +export SCIM_USER='{ + "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" : "nick@example.com", + "userName" : "nick", + "displayName" : "The Nick" +}' +``` + +The `externalId` is used to construct a SAML identity. Two cases are +currently supported: + +1. `externalId` contains a valid email address. + The SAML `NameID` has the form `me@example.com`. +2. `externalId` contains anything that is *not* an email address. + The SAML `NameID` has the form `...`. + +```{note} +It is important to configure your SAML provider to use `nameid-format:emailAddress` or `nameid-format:unspecified`. Other nameid formats are not supported at this moment. + +See [FAQ](https://docs.wire.com/how-to/single-sign-on/trouble-shooting.html#how-should-i-map-user-data-to-scim-attributes-when-provisioning-users-via-scim) +``` + +We also support custom fields that are used in rich profiles in this form (see: ): + +```{code-block} bash +:linenos: true + + export SCIM_USER='{ + "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:wire:scim:schemas:profile:1.0"], + "externalId" : "rnick@example.com", + "userName" : "rnick", + "displayName" : "The Rich Nick", + "urn:wire:scim:schemas:profile:1.0": { + "richInfo": [ + { + "type": "Department", + "value": "Sales & Marketing" + }, + { + "type": "Favorite color", + "value": "Blue" + } + ] + } + }' +``` + +**How to create a user** + +You can create a user using the following command: + +```{code-block} bash +:linenos: true + + export STORED_USER=$(curl -X POST \ + --header "Authorization: Bearer $SCIM_TOKEN" \ + --header 'Content-Type: application/json;charset=utf-8' \ + -d "$SCIM_USER" \ + $WIRE_BACKEND/scim/v2/Users) + export STORED_USER_ID=$(echo $STORED_USER | jq -r .id) +``` + +Note that `$SCIM_USER` is in the JSON format and is declared before running this commend as described in the section above. + +**Get a specific user** + +```{code-block} bash +:linenos: true + + curl -X GET \ + --header "Authorization: Bearer $SCIM_TOKEN" \ + --header 'Content-Type: application/json;charset=utf-8' \ + $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID +``` + +**Search a specific user** + +SCIM user search is quite flexible. Wire currently only supports lookup by wire handle or email address. + +Email address (and/or SAML NameID, if /a): + +```{code-block} bash +:linenos: true + + curl -X GET \ + --header "Authorization: Bearer $SCIM_TOKEN" \ + --header 'Content-Type: application/json;charset=utf-8' \ + $WIRE_BACKEND/scim/v2/Users/'?filter=externalId%20eq%20%22me%40example.com%22' +``` + +Wire handle: same request, just replace the query part with + +```bash +'?filter=userName%20eq%20%22me%22' +``` + +**Update a specific user** + +For each put request, you need to provide the full json object. All omitted fields will be set to `null`. (If you do not have an up-to-date user present, just `GET` one right before the `PUT`.) + +```{code-block} bash +:linenos: true + + export SCIM_USER='{ + "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" : "rnick@example.com", + "userName" : "newnick", + "displayName" : "The New Nick" + }' +``` + +```{code-block} bash +:linenos: true + + curl -X PUT \ + --header "Authorization: Bearer $SCIM_TOKEN" \ + --header 'Content-Type: application/json;charset=utf-8' \ + -d "$SCIM_USER" \ + $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID +``` + +**Deactivate user** + +It is possible to temporarily deactivate an user (and reactivate him later) by setting his `active` property to `true/false` without affecting his device history. (`active=false` changes the wire user status to `suspended`.) + +**Delete user** + +```{code-block} bash +:linenos: true + + curl -X DELETE \ + --header "Authorization: Bearer $SCIM_TOKEN" \ + $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID +``` diff --git a/docs/src/understand/single-sign-on/token-step-01.png b/docs/src/how-to/single-sign-on/understand/token-step-01.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-01.png rename to docs/src/how-to/single-sign-on/understand/token-step-01.png diff --git a/docs/src/understand/single-sign-on/token-step-02.png b/docs/src/how-to/single-sign-on/understand/token-step-02.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-02.png rename to docs/src/how-to/single-sign-on/understand/token-step-02.png diff --git a/docs/src/understand/single-sign-on/token-step-03.png b/docs/src/how-to/single-sign-on/understand/token-step-03.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-03.png rename to docs/src/how-to/single-sign-on/understand/token-step-03.png diff --git a/docs/src/understand/single-sign-on/token-step-04.png b/docs/src/how-to/single-sign-on/understand/token-step-04.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-04.png rename to docs/src/how-to/single-sign-on/understand/token-step-04.png diff --git a/docs/src/understand/single-sign-on/token-step-05.png b/docs/src/how-to/single-sign-on/understand/token-step-05.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-05.png rename to docs/src/how-to/single-sign-on/understand/token-step-05.png diff --git a/docs/src/understand/single-sign-on/token-step-06.png b/docs/src/how-to/single-sign-on/understand/token-step-06.png similarity index 100% rename from docs/src/understand/single-sign-on/token-step-06.png rename to docs/src/how-to/single-sign-on/understand/token-step-06.png diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 0000000000..c5b3d5b4db --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,46 @@ +% Wire documentation master file, created by +% sphinx-quickstart on Thu Jul 18 13:44:11 2019. +% You can adapt this file completely to your liking, but it should at least +% contain the root `toctree` directive. + +# Welcome to Wire's documentation! + +If you are a Wire end-user, please check out our [support pages](https://support.wire.com/). + +The targeted audience of this documentation is: + +- the curious power-user (people who want to understand how the server components of Wire work) +- on-premise operators/administrators (people who want to self-host Wire-Server on their own datacentres or cloud) +- developers (people who are working with the wire-server source code) + +If you are a developer, you may want to check out the "Notes for developers" first. + +This documentation may be expanded in the future to cover other aspects of Wire. + +```{toctree} +:caption: 'Contents:' +:glob: true +:maxdepth: 1 + +Release notes +Administrator's Guide +Understanding wire-server components +Single-Sign-On and user provisioning +Client API documentation +Security responses +Notes for developers +``` + +% Overview + +% commented out for now... + +% Indices and tables + +% ================== + +% * :ref:`genindex` + +% * :ref:`modindex` + +% * :ref:`search` diff --git a/docs/src/index.rst b/docs/src/index.rst deleted file mode 100644 index 28721d822a..0000000000 --- a/docs/src/index.rst +++ /dev/null @@ -1,43 +0,0 @@ -.. Wire documentation master file, created by - sphinx-quickstart on Thu Jul 18 13:44:11 2019. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to Wire's documentation! -=============================================== - -If you are a Wire end-user, please check out our `support pages `_. - -The targeted audience of this documentation is: - -* the curious power-user (people who want to understand how the server components of Wire work) -* on-premise operators/administrators (people who want to self-host Wire-Server on their own datacentres or cloud) -* developers (people who are working with the wire-server source code) - -If you are a developer, you may want to check out the "Notes for developers" first. - -This documentation may be expanded in the future to cover other aspects of Wire. - -.. toctree:: - :maxdepth: 1 - :caption: Contents: - :glob: - - Release notes - Administrator's Guide - Understanding wire-server components - Administrator's manual: single-sign-on and user provisioning - Client API documentation - Security responses - Notes for developers - -.. Overview - -.. commented out for now... - -.. Indices and tables -.. ================== - -.. * :ref:`genindex` -.. * :ref:`modindex` -.. * :ref:`search` diff --git a/docs/src/release-notes.rst b/docs/src/release-notes.md similarity index 51% rename from docs/src/release-notes.rst rename to docs/src/release-notes.md index 497af3cca3..478db87668 100644 --- a/docs/src/release-notes.rst +++ b/docs/src/release-notes.md @@ -1,14 +1,13 @@ -.. _release-notes: +(release-notes)= -Release notes -------------- +# Release notes This page previously contained the release notes for the project, and they were manually updated each time a new release was done, due to limitations in Github's «releases» feature. -However, Github since updated the feature, making this page un-necessary. +However, Github since updated the feature, making this page un-necessary. -Go to → `GitHub - wireapp/wire-server: Wire back-end services `_ +Go to → [GitHub - wireapp/wire-server: Wire back-end services](https://github.com/wireapp/wire-server/) -→ Look at releases on right hand side. They are shown by date of release. `Release Notes `_ +→ Look at releases on right hand side. They are shown by date of release. [Release Notes](https://github.com/wireapp/wire-server/releases) -→ Open the CHANGELOG.md. This will give you chart version. \ No newline at end of file +→ Open the CHANGELOG.md. This will give you chart version. diff --git a/docs/src/security-responses/2021-12-15_log4shell.md b/docs/src/security-responses/2021-12-15_log4shell.md new file mode 100644 index 0000000000..b567c21e74 --- /dev/null +++ b/docs/src/security-responses/2021-12-15_log4shell.md @@ -0,0 +1,90 @@ +# 2021-12 - log4shell + +Last updated: 2021-12-15 + +This page concerns ON-PREMISE (i.e. self-hosted) installations of wire-server as documented in and its possible vulnerability to “log4shell” / CVE-2021-44228 and CVE-2021-45046. + +## Introduction + +The “log4shell” vulnerability ([CVE-2021-44228](https://www.cve.org/CVERecord?id=CVE-2021-44228) and [CVE-2021-45046](https://www.cve.org/CVERecord?id=CVE-2021-45046)) concerns a logging library “log4j” used in Java or JVM software components. + +- Wire-server’s source code is not written in a JVM language (it's written mostly in Haskell), and as such, is not vulnerable. + +- Wire-server makes use of Cassandra, which is running on the JVM, however as of version 2.1 no longer makes use of log4j (it uses logback). Since the start of Wire’s on-premise product, we have used Cassandra versions > 3 (currently 3.11), which is not vulnerable. + +- Wire-server makes use of **Elasticsearch**, which **does use log4j. See the section below for details**. + +- All other components Wire-server’s on-premise current and near-time-future product relies on are not based on the JVM and as such are not vulnerable: + + > - Calling restund/SFT servers: written in C + > - Minio: written in Go + > - Redis: written in C + > - Nginx: written in C + > - Wire-Server: written in Haskell + > - Wire-Frontend (webapp, team settings): written in Javascript / NodeJS + > - Fake-aws components: based on localstack written in python or for SQS written in ruby + > - fake-aws-dynamodb: this component is JVM based and was used in the past on on-premise installations, but should not be in use anymore these days. If it is still in use in your environment, please stop using it: all recent versions of wire-server since June 2021 will not make use of that component anymore. Even if still in use, it does not store or log any user-provided data nor is it internet-facing and as such should pose little to no risk. + > - Upcoming releases may have wire-server-metrics: prometheus (Ruby), node-exporter (Golang) and Grafana (Golang) + > - Upcoming releases may have: Logging/Kibana: fluent-bit (C), Kibana (JavaScript), ElasticSearch (covered in section below) + +## Elasticsearch + +Wire uses Elasticsearch for for storing indexes used when searching for users in Wire. + +Elasticsearch clusters are not directly user-facing or internet-facing and it is therefore not immediately possible to inject problematic exploit strings into elasticsearch’s own logging (i.e. elasticsearch stores user-provided data, but doesn’t itself log this data). + +*Example: A Wire user display name will be stored inside elasticsearch, but not logged by elasticsearch (elasticsearch logs mostly contain information about connectivity to other elasticsearch processes)* + +Hypothetically, the log4shell exploit could be combined with another exploit which would allow an attacker to get Elasticsearch to log some of the data stored inside its cluster. As elasticsearch is not internet-facing, this doesn’t look easy to exploit. + +In addition as per Elastics’s [own information on the matter](https://discuss.elastic.co/t/apache-log4j2-remote-code-execution-rce-vulnerability-cve-2021-44228-esa-2021-31/291476) + +> "Elasticsearch 6 and 7 are not susceptible to remote code execution with this vulnerability due to our use of the Java Security Manager. Investigation into Elasticsearch 5 is ongoing. Elasticsearch running on JDK8 or below is susceptible to an information leak via DNS which is fixable by the JVM property identified below. The JVM option identified below is effective for Elasticsearch versions 5.5+, 6.5+, and 7+" + +The JVM property referred to is `-Dlog4j2.formatMsgNoLookups=true` + +[Update 15th December about CVE-2021-45046 from Elasitic](https://discuss.elastic.co/t/apache-log4j2-remote-code-execution-rce-vulnerability-cve-2021-44228-esa-2021-31/291476): + +> "Update 15 December: A further vulnerability (CVE-2021-45046) was disclosed on December 14th after it was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations. Our guidance for Elasticsearch \[...\] are unchanged by this new vulnerability" + +Wire on-premise installations contain a version of Elasticsearch between \[`6.6.0` and `6.8.18`\] at the time of writing. + +**As such, while ElasticSearch is affected, it is A. only susceptible to an information leak, not to remote code execution and B. not easily exploitable due to the way Wire uses ElasticSearch.** + +Still, if you’d like to avoid even the potential information leak problem: + +## Disable log4jLookups: + +If you have followed our official documentation on [https://docs.wire.com](https://docs.wire.com), then Elasticsearch on premise was set up using [wire-server-deploy](https://github.com/wireapp/wire-server-deploy) using the `./ansible/elasticsearch.yml` playbook, which installs a vulnerable Log4J `2.11.1`: + +``` +find / | grep -i log4j +./etc/elasticsearch/HOSTNAME/log4j2.properties +./usr/share/elasticsearch/lib/log4j-core-2.11.1.jar +./usr/share/elasticsearch/lib/log4j-1.2-api-2.11.1.jar +./usr/share/elasticsearch/lib/log4j-api-2.11.1.jar +``` + +The BSI [recommends](https://www.bsi.bund.de/SharedDocs/Cybersicherheitswarnungen/DE/2021/2021-549032-10F2.pdf?__blob=publicationFile&v=3) to mitigate setting the `log4j2.formatMsgNoLookups` to True in the JVM options. Elastic [recommends](https://discuss.elastic.co/t/apache-log4j2-remote-code-execution-rce-vulnerability-cve-2021-44228-esa-2021-31/291476) the same mitigation. + +You can do this in the concrete Wire on-premise case using: + +First, ssh to all your elasticsearch machines and do the following: + +```shell +find /etc/elasticsearch | grep jvm.options + +# set this variable with the filepath found from above, usually something like +# /etc/elasticsearch//jvm.options +JVM_OPTIONS_FILE= + +# run the following to add the mitigation log4j flag (command is idempotent) +grep "\-Dlog4j2.formatMsgNoLookups=True" "$JVM_OPTIONS_FILE" || echo "-Dlog4j2.formatMsgNoLookups=True" >> "$JVM_OPTIONS_FILE" +``` + +Next, restart your cluster using instructions provided in {ref}`restart-elasticsearch`. + +## Further information + +- A mitigation for this with fresh on-premise installations is introduced in [https://github.com/wireapp/wire-server-deploy/pull/526](https://github.com/wireapp/wire-server-deploy/pull/526) +- We have of course fully applied the above counter measures to our cloud offering. We have no evidence that this vulnerability was used to launch an attack before this. Any hypothetical undetected attack would have required additional security vulnerabilities to be successful. diff --git a/docs/src/security-responses/2021-12-15_log4shell.rst b/docs/src/security-responses/2021-12-15_log4shell.rst deleted file mode 100644 index 741d2622cc..0000000000 --- a/docs/src/security-responses/2021-12-15_log4shell.rst +++ /dev/null @@ -1,103 +0,0 @@ -2021-12 - log4shell --------------------- - -Last updated: 2021-12-15 - -This page concerns ON-PREMISE (i.e. self-hosted) installations of wire-server as documented in https://docs.wire.com and its possible vulnerability to “log4shell” / CVE-2021-44228 and CVE-2021-45046. - -Introduction -~~~~~~~~~~~~~ - -The “log4shell” vulnerability (`CVE-2021-44228 `__ and `CVE-2021-45046 `__) concerns a logging library “log4j” used in Java or JVM software components. - -* Wire-server’s source code is not written in a JVM language (it's written mostly in Haskell), and as such, is not vulnerable. - -* Wire-server makes use of Cassandra, which is running on the JVM, however as of version 2.1 no longer makes use of log4j (it uses logback). Since the start of Wire’s on-premise product, we have used Cassandra versions > 3 (currently 3.11), which is not vulnerable. - -* Wire-server makes use of **Elasticsearch**, which **does use log4j. See the section below for details**. - -* All other components Wire-server’s on-premise current and near-time-future product relies on are not based on the JVM and as such are not vulnerable: - - * Calling restund/SFT servers: written in C - - * Minio: written in Go - - * Redis: written in C - - * Nginx: written in C - - * Wire-Server: written in Haskell - - * Wire-Frontend (webapp, team settings): written in Javascript / NodeJS - - * Fake-aws components: based on localstack written in python or for SQS written in ruby - - * fake-aws-dynamodb: this component is JVM based and was used in the past on on-premise installations, but should not be in use anymore these days. If it is still in use in your environment, please stop using it: all recent versions of wire-server since June 2021 will not make use of that component anymore. Even if still in use, it does not store or log any user-provided data nor is it internet-facing and as such should pose little to no risk. - - * Upcoming releases may have wire-server-metrics: prometheus (Ruby), node-exporter (Golang) and Grafana (Golang) - - * Upcoming releases may have: Logging/Kibana: fluent-bit (C), Kibana (JavaScript), ElasticSearch (covered in section below) - -Elasticsearch -~~~~~~~~~~~~~ - -Wire uses Elasticsearch for for storing indexes used when searching for users in Wire. - -Elasticsearch clusters are not directly user-facing or internet-facing and it is therefore not immediately possible to inject problematic exploit strings into elasticsearch’s own logging (i.e. elasticsearch stores user-provided data, but doesn’t itself log this data). - -*Example: A Wire user display name will be stored inside elasticsearch, but not logged by elasticsearch (elasticsearch logs mostly contain information about connectivity to other elasticsearch processes)* - -Hypothetically, the log4shell exploit could be combined with another exploit which would allow an attacker to get Elasticsearch to log some of the data stored inside its cluster. As elasticsearch is not internet-facing, this doesn’t look easy to exploit. - -In addition as per Elastics’s `own information on the matter `__ - - "Elasticsearch 6 and 7 are not susceptible to remote code execution with this vulnerability due to our use of the Java Security Manager. Investigation into Elasticsearch 5 is ongoing. Elasticsearch running on JDK8 or below is susceptible to an information leak via DNS which is fixable by the JVM property identified below. The JVM option identified below is effective for Elasticsearch versions 5.5+, 6.5+, and 7+" - -The JVM property referred to is ``-Dlog4j2.formatMsgNoLookups=true`` - -`Update 15th December about CVE-2021-45046 from Elasitic `__: - - "Update 15 December: A further vulnerability (CVE-2021-45046) was disclosed on December 14th after it was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations. Our guidance for Elasticsearch [...] are unchanged by this new vulnerability" - -Wire on-premise installations contain a version of Elasticsearch between [``6.6.0`` and ``6.8.18``] at the time of writing. - -**As such, while ElasticSearch is affected, it is A. only susceptible to an information leak, not to remote code execution and B. not easily exploitable due to the way Wire uses ElasticSearch.** - -Still, if you’d like to avoid even the potential information leak problem: - -Disable log4jLookups: -~~~~~~~~~~~~~~~~~~~~~ - -If you have followed our official documentation on ``__, then Elasticsearch on premise was set up using `wire-server-deploy `__ using the ``./ansible/elasticsearch.yml`` playbook, which installs a vulnerable Log4J ``2.11.1``:: - - find / | grep -i log4j - ./etc/elasticsearch/HOSTNAME/log4j2.properties - ./usr/share/elasticsearch/lib/log4j-core-2.11.1.jar - ./usr/share/elasticsearch/lib/log4j-1.2-api-2.11.1.jar - ./usr/share/elasticsearch/lib/log4j-api-2.11.1.jar - -The BSI `recommends `__ to mitigate setting the ``log4j2.formatMsgNoLookups`` to True in the JVM options. Elastic `recommends `__ the same mitigation. - -You can do this in the concrete Wire on-premise case using: - -First, ssh to all your elasticsearch machines and do the following: - -.. code:: shell - - find /etc/elasticsearch | grep jvm.options - - # set this variable with the filepath found from above, usually something like - # /etc/elasticsearch//jvm.options - JVM_OPTIONS_FILE= - - # run the following to add the mitigation log4j flag (command is idempotent) - grep "\-Dlog4j2.formatMsgNoLookups=True" "$JVM_OPTIONS_FILE" || echo "-Dlog4j2.formatMsgNoLookups=True" >> "$JVM_OPTIONS_FILE" - -Next, restart your cluster using instructions provided in :ref:`restart-elasticsearch`. - -Further information -~~~~~~~~~~~~~~~~~~~ - -* A mitigation for this with fresh on-premise installations is introduced in ``__ - -* We have of course fully applied the above counter measures to our cloud offering. We have no evidence that this vulnerability was used to launch an attack before this. Any hypothetical undetected attack would have required additional security vulnerabilities to be successful. diff --git a/docs/src/security-responses/index.md b/docs/src/security-responses/index.md new file mode 100644 index 0000000000..a0c58f66ff --- /dev/null +++ b/docs/src/security-responses/index.md @@ -0,0 +1,14 @@ +(security-responses)= + +# Security responses + +% comment: The toctree directive below takes a list of the pages you want to appear in order, +% and '*' is used to include any other pages in the federation directory in alphabetical order + +```{toctree} +:glob: true +:maxdepth: 1 +:reversed: true + +* +``` diff --git a/docs/src/security-responses/index.rst b/docs/src/security-responses/index.rst deleted file mode 100644 index 1c1e3077c0..0000000000 --- a/docs/src/security-responses/index.rst +++ /dev/null @@ -1,16 +0,0 @@ -.. _security_responses: - -++++++++++++++++++ -Security responses -++++++++++++++++++ - -.. - comment: The toctree directive below takes a list of the pages you want to appear in order, - and '*' is used to include any other pages in the federation directory in alphabetical order - -.. toctree:: - :maxdepth: 1 - :glob: - :reversed: - - * diff --git a/docs/src/understand/api-client-perspective/authentication.md b/docs/src/understand/api-client-perspective/authentication.md new file mode 100644 index 0000000000..51cb738b85 --- /dev/null +++ b/docs/src/understand/api-client-perspective/authentication.md @@ -0,0 +1,435 @@ +# Authentication + +% useful vim replace commands when porting markdown -> restructured text: + +% :%s/.. raw:: html//g + +% :%s/ /.. _\1:/gc + +## Access Tokens + +The authentication protocol used by the API is loosely inspired by the +[OAuth2 protocol](http://oauth.net/2/). As such, API requests are +authorised through so-called [bearer +tokens](https://tools.ietf.org/html/rfc6750). For as long as a bearer +token is valid, it grants access to the API under the identity of the +user whose credentials have been used for the [login]. The +current validity of access tokens is `15 minutes`, however, that may +change at any time without prior notice. + +In order to obtain new access tokens without having to ask the user for +his credentials again, so-called "user tokens" are issued which are +issued in the form of a `zuid` HTTP +[cookie](https://en.wikipedia.org/wiki/HTTP_cookie). These cookies +have a long lifetime (if {ref}`persistent ` typically +at least a few months) and their use is strictly limited to the +{ref}`/access ` endpoint used for token refresh. +{ref}`Persistent ` access cookies are regularly +refreshed as part of an {ref}`access token refresh `. + +An access cookie is obtained either directly after registration or through a +subsequent {ref}`login `. A successful login provides both an access +cookie and and access token. Both access token and cookie must be stored safely +and kept confidential. User passwords should not be stored. + +As of yet, there is no concept of authorising third-party applications to +perform operations on the API on behalf of a user (Notable exceptions: +{ref}`sso`). Such functionality may be provided in the future through +standardised OAuth2 flows. + +To authorise an API request, the access token must be provided via the +HTTP `Authorization` header with the `Bearer` scheme as follows: + +``` +Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... +``` + +While the API currently also supports passing the access token in the +query string of a request, this approach is highly discouraged as it +unnecessarily exposes access tokens (e.g. in server logs) and thus might +be removed in the future. + +(login)= + +## Login - `POST /login` + +A login is the process of authenticating a user either through a known secret in +a {ref}`password login ` or by proving ownership of a verified +phone number associated with an account in an {ref}`SMS login `. The +response to a successful login contains an access cookie in a `Set-Cookie` +header and an access token in the JSON response body. + +(login-cookies)= + +### Cookies + +There is a hard limit for the number of session-scoped access cookies and the same +amount of persistent access cookies per user account. When this number is +reached, old cookies are removed when new ones are issued. Thereby, the cookies +with the oldest expiration timestamp are removed first. The removal takes the +type of the cookie to issue into account. I.e. session cookies are replaced by +session cookies, persistent cookies are replaced by persistent cookies. + +To prevent performance issues and malicious usages of the API, there is a +throttling mechanism in place. When the maximum number of cookies of one type +are issued, it's checked that login calls don't happen too frequently (too +quickly after one another.) + +In case of throttling no cookie gets issued. The error response ([HTTP status +code 429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429)) has +a `Retry-After` header which specifies the time to wait before accepting the +next request in Seconds. + +Being throttled is a clear indicator of incorrect API usage. There is no need to +login many times in a row on the same device. Instead, the cookie should be +re-used. + +The corresponding backend configuration settings are described in: +{ref}`auth-cookie-config` . + +(login-password)= + +### Password Login + +To perform a password login, send a `POST` request to the `/login` +endpoint, providing either a verified email address or phone number and +the corresponding password. For example: + +``` +POST /login HTTP/1.1 +[headers omitted] + +{ + "email": "me@wire.com", + "password": "Quo2Booz" +} +``` + +If a phone number is used, the `phone` field is used instead of +`email`. If a @handle is used, the `handle` field is used instead of +`email` (note that the handle value should be sent *without* the `@` +symbol). Assuming the credentials are correct, the API will respond with +a `200 OK` and an access token and cookie: + +``` +HTTP/1.1 200 OK +zuid=...; Expires=Fri, 02-Aug-2024 09:15:54 GMT; Domain=zinfra.io; Path=/access; HttpOnly; Secure +[other headers omitted] + +{ + "expires_in": 900, + "access_token": "fmmLpDSjArpksFv57r5rDrzZZlj...", + "token_type": "Bearer" +} +``` + +% + +> The `Domain` of the cookie will be different depending on the +> environment. + +The value of `expires_in` is the number of seconds that the +`access_token` is valid from the moment it was issued. + +As of yet, the `token_type` is always `Bearer`. + +(login-sms)= + +### SMS Login + +To perform an SMS login, first request an SMS code to be sent to a +verified phone number: + +``` +POST /login/send HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890" +} +``` + +An SMS with a short-lived login code will be sent. Upon receiving the +SMS and extracting the code from it, the login can be performed using +the `phone` and `code` as follows: + +``` +POST /login HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890", + "code": "123456" +} +``` + +A successful response is identical to that of a {ref}`password +login `. + +(login-persistent)= + +### Persistent Logins + +By default, access cookies are issued as [session +cookies](https://en.wikipedia.org/wiki/HTTP_cookie#Session_cookie) +with a validity of 1 week. Furthermore, these session cookies are not +refreshed as part of an {ref}`access token refresh `. To +request a `persistent` access cookie which does get refreshed, specify +the `persist=true` parameter during a login: + +``` +POST /login?persist=true HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890", + "code": "123456" +} +``` + +All access cookies returned on registration are persistent. + +(token-refresh)= + +### FAQ: is my cookie a persistent cookie or a session cookie? + +When you log in **without** the `persist=true` query parameter, or +with persist=false, you get a `session cookie`, which means it has no +expiration date set, and will expire when you close the browser (and on +the backend has a validity of max 1 day or 1 week (configurable, see +current config in [hegemony](https://github.com/zinfra/hegemony)). +Example **session cookie**: + +``` +POST /login?persist=false + +Set-Cookie: zuid=(redacted); Path=/access; Domain=zinfra.io; HttpOnly; Secure +``` + +When you log in **with** `persist=true`, you get a persistent cookie, +which means it has *some* expiration date. In production this is +currently 56 days (again, configurable, check current config in +[hegemony](https://github.com/zinfra/hegemony)) and can be renewed +during token refresh. Example **persistent cookie**: + +``` +POST /login?persist=true + +Set-Cookie: zuid=(redacted); Path=/access; Expires=Thu, 10-Jan-2019 10:43:28 GMT; Domain=zinfra.io; HttpOnly; Secure +``` + +## Token Refresh - `POST /access` + +Since access tokens have a relatively short lifetime to limit the time +window of abuse for a captured token, they need to be regularly +refreshed. In order to refresh an access token, send a `POST` reques +to `/access`, including the access cookie in the `Cookie` header and +the old (possibly expired) access token in the `Authorization` header: + +``` +POST /access HTTP/1.1 +Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... +Cookie: zuid=... +[other headers omitted] + + +``` + +Providing the old access token is not required but strongly recommended +as it will link the new access token to the old, enabling the API to see +the new access token as a continued session of the same client. + +As part of an access token refresh, the response may also contain a new +`zuid` access cookie in form of a `Set-Cookie` header. A client must +expect a new `zuid` cookie as part of any access token refresh and +replace the existing cookie appropriately. + +(cookies-1)= + +## Cookie Management + +(cookies-logout)= + +### Logout - `POST /access/logout` + +An explicit logout effectively deletes the cookie used to perform the +operation: + +``` +POST /access/logout HTTP/1.1 +Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... +Cookie: zuid=... +[other headers omitted] + + +``` + +Afterwards, the cookie that was sent as part of the `Cookie` header is +no longer valid. + +If a client offers an explicit logout, this operation must be performed. +An explicit logout is especially important for Web clients. + +(cookies-labels)= + +### Labels + +Cookies can be labeled by specifying a `label` during login or +registration, e.g.: + +``` +POST /login?persist=true HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890", + "code": "123456", + "label": "Google Nexus 5" +} +``` + +Specifying a label is recommended as it helps to identify the cookies in a +user-friendly way and allows {ref}`selective revocation ` based +on the labels. + +(cookies-list)= + +### Listing Cookies - `GET /cookies` + +To list the cookies currently associated with an account, send a `GET` +request to `/cookies`. The response will contain a list of cookies, +e.g.: + +``` +HTTP/1.1 200 OK +[other headers omitted] + +{ + "cookies": [ + { + "time": "2015-06-04T14:29:23.000Z", + "id": 967153183, + "type": "session", + "label": null + }, + { + "time": "2015-06-04T14:44:23.000Z", + "id": 942451749, + "type": "session", + "label": null + }, + ... + ] +} +``` + +Note that expired cookies are not automatically removed when they +expire, only as new cookies are issued. + +(cookies-revoke)= + +### Revoking Cookies - `POST /cookies/remove` + +Cookies can be removed individually or in bulk either by specifying the full +cookie structure as it is returned by {ref}`GET /cookies ` or only +by their labels in a `POST` request to `/cookies/remove`, alongside with the +user's credentials: + +``` +POST /cookies/remove HTTP/1.1 +[headers omitted] + +{ + "ids": [{}, {}, ...], + "labels": ["", "", ...] + "email": "me@wire.com", + "password": "secret" +} +``` + +Cookie removal currently requires an account with an email address and +password. + +(password-reset)= + +## Password Reset - `POST /password-reset` + +A password reset can be used to set a new password if the existing password +associated with an account has been forgotten. This is not to be confused with +the act of merely changing your password for the purpose of password rotation or +if you suspect your current password to be compromised. + +### Initiate a Password Reset + +To initiate a password reset, send a `POST` request to +`/password-reset`, specifying either a verified email address or phone +number for the account in question: + +``` +POST /password-reset HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890" +} +``` + +For a phone number, the `phone` field would be used instead. As a +result of a successful request, either a password reset key and code is +sent via email or a password reset code is sent via SMS, depending on +whether an email address or a phone number was provided. Password reset +emails will contain a link to the [wire.com](https://www.wire.com/) +website which will guide the user through the completion of the password +reset, which means that the website will perform the necessary requests +to complete the password reset. To complete a password reset initiated +with a phone number, the completion of the password reset has to happen +from the mobile client application itself. + +Once a password reset has been initiated for an email address or phone +number, no further password reset can be initiated for the same email +address or phone number before the prior reset is completed or times +out. The current timeout for an initiated password reset is +`10 minutes`. + +### Complete a Password Reset + +To complete a password reset, the password reset code, together with the +new password and the `email` or `phone` used when initiating the +reset (or the opaque `key` sent by mail) are sent to +`/password-reset/complete` in a `POST` request: + +``` +POST /password-reset/complete HTTP/1.1 +[headers omitted] + +{ + "phone": "+1234567890", + "code": "123456", + "password": "new-secret-password" +} +``` + +There is a maximum of `3` attempts at completing a password reset, +after which the password reset code becomes invalid and a new password +reset must be initiated. + +A completed password reset results in all access cookies to be revoked, +requiring the user to {ref}`login `. + +## Related topics: SSO, Legalhold + +(sso)= + +### Single Sign-On + +Users that are part of a team, for which a team admin has configured SSO (Single Sign On), authentication can happen through SAML. + +More information: + +- {ref}`FAQ ` +- [setup howtos for various IdP vendors](https://docs.wire.com/how-to/single-sign-on/index.html) +- [a few fragments that may help admins](https://github.com/wireapp/wire-server/blob/develop/docs/reference/spar-braindump.md) + +### LegalHold + +Users that are part of a team, for which a team admin has configured "LegalHold", can add a so-called "LegalHold" device. The endpoints in use to authenticate for a "LegalHold" Device are the same as for regular users, but the access tokens they get can only use a restricted set of API endpoints. See also [legalhold documentation on wire-server](https://github.com/wireapp/wire-server/blob/develop/docs/reference/team/legalhold.md) diff --git a/docs/src/understand/api-client-perspective/authentication.rst b/docs/src/understand/api-client-perspective/authentication.rst deleted file mode 100644 index 52630c58a6..0000000000 --- a/docs/src/understand/api-client-perspective/authentication.rst +++ /dev/null @@ -1,476 +0,0 @@ -Authentication -============== - -.. useful vim replace commands when porting markdown -> restructured text: -.. :%s/.. raw:: html//g -.. :%s/ /.. _\1:/gc - -Access Tokens -------------- - -The authentication protocol used by the API is loosely inspired by the -`OAuth2 protocol `__. As such, API requests are -authorised through so-called `bearer -tokens `__. For as long as a bearer -token is valid, it grants access to the API under the identity of the -user whose credentials have been used for the login_. The -current validity of access tokens is ``15 minutes``, however, that may -change at any time without prior notice. - -In order to obtain new access tokens without having to ask the user for -his credentials again, so-called "user tokens" are issued which are -issued in the form of a ``zuid`` HTTP -`cookie `__. These cookies -have a long lifetime (if `persistent <#login-persistent>`__, typically -at least a few months) and their use is strictly limited to the -`/access <#token-refresh>`__ endpoint used for token refresh. -`Persistent <#login-persistent>`__ access cookies are regularly -refreshed as part of an `access token refresh <#token-refresh>`__. - -An access cookie is obtained either directly after -`registration `__ or through a -subsequent `login <#login>`__. A successful login provides both an -access cookie and and access token. Both access token and cookie must be -stored safely and kept confidential. User passwords should not be -stored. - -As of yet, there is no concept of authorising third-party applications to -perform operations on the API on behalf of a user (Notable exceptions: -:ref:`sso`). Such functionality may be provided in the future through -standardised OAuth2 flows. - -To authorise an API request, the access token must be provided via the -HTTP ``Authorization`` header with the ``Bearer`` scheme as follows: - -:: - - Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... - -While the API currently also supports passing the access token in the -query string of a request, this approach is highly discouraged as it -unnecessarily exposes access tokens (e.g. in server logs) and thus might -be removed in the future. - -.. _login: - -Login - ``POST /login`` ------------------------ - -A login is the process of authenticating a user either through a known -secret in a `password login <#login-password>`__ or by proving ownership -of a verified phone number associated with an account in an `SMS -login <#login-sms>`__. The response to a successful login contains an -access cookie in a ``Set-Cookie`` header and an access token in the JSON -response body. - -.. _login-cookies: - -Cookies -~~~~~~~ - -There is a hard limit for the number of session-scoped access cookies and the same -amount of persistent access cookies per user account. When this number is -reached, old cookies are removed when new ones are issued. Thereby, the cookies -with the oldest expiration timestamp are removed first. The removal takes the -type of the cookie to issue into account. I.e. session cookies are replaced by -session cookies, persistent cookies are replaced by persistent cookies. - -To prevent performance issues and malicious usages of the API, there is a -throttling mechanism in place. When the maximum number of cookies of one type -are issued, it's checked that login calls don't happen too frequently (too -quickly after one another.) - -In case of throttling no cookie gets issued. The error response (`HTTP status -code 429 `_) has -a ``Retry-After`` header which specifies the time to wait before accepting the -next request in Seconds. - -Being throttled is a clear indicator of incorrect API usage. There is no need to -login many times in a row on the same device. Instead, the cookie should be -re-used. - -The corresponding backend configuration settings are described in: -:ref:`auth-cookie-config` . - -.. _login-password: - -Password Login -~~~~~~~~~~~~~~ - -To perform a password login, send a ``POST`` request to the ``/login`` -endpoint, providing either a verified email address or phone number and -the corresponding password. For example: - -:: - - POST /login HTTP/1.1 - [headers omitted] - - { - "email": "me@wire.com", - "password": "Quo2Booz" - } - -If a phone number is used, the ``phone`` field is used instead of -``email``. If a @handle is used, the ``handle`` field is used instead of -``email`` (note that the handle value should be sent *without* the ``@`` -symbol). Assuming the credentials are correct, the API will respond with -a ``200 OK`` and an access token and cookie: - -:: - - HTTP/1.1 200 OK - zuid=...; Expires=Fri, 02-Aug-2024 09:15:54 GMT; Domain=zinfra.io; Path=/access; HttpOnly; Secure - [other headers omitted] - - { - "expires_in": 900, - "access_token": "fmmLpDSjArpksFv57r5rDrzZZlj...", - "token_type": "Bearer" - } - -.. - - The ``Domain`` of the cookie will be different depending on the - environment. - -The value of ``expires_in`` is the number of seconds that the -``access_token`` is valid from the moment it was issued. - -As of yet, the ``token_type`` is always ``Bearer``. - - - -.. _login-sms: - -SMS Login -~~~~~~~~~ - -To perform an SMS login, first request an SMS code to be sent to a -verified phone number: - -:: - - POST /login/send HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890" - } - -An SMS with a short-lived login code will be sent. Upon receiving the -SMS and extracting the code from it, the login can be performed using -the ``phone`` and ``code`` as follows: - -:: - - POST /login HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890", - "code": "123456" - } - -A successful response is identical to that of a `password -login <#login-password>`__. - - - -.. _login-persistent: - -Persistent Logins -~~~~~~~~~~~~~~~~~ - -By default, access cookies are issued as `session -cookies `__ -with a validity of 1 week. Furthermore, these session cookies are not -refreshed as part of an `access token refresh <#token-refresh>`__. To -request a ``persistent`` access cookie which does get refreshed, specify -the ``persist=true`` parameter during a login: - -:: - - POST /login?persist=true HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890", - "code": "123456" - } - -All access cookies returned on registration are persistent. - - - -.. _token-refresh: - -FAQ: is my cookie a persistent cookie or a session cookie? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When you log in **without** the ``persist=true`` query parameter, or -with persist=false, you get a ``session cookie``, which means it has no -expiration date set, and will expire when you close the browser (and on -the backend has a validity of max 1 day or 1 week (configurable, see -current config in `hegemony `__). -Example **session cookie**: - -:: - - POST /login?persist=false - - Set-Cookie: zuid=(redacted); Path=/access; Domain=zinfra.io; HttpOnly; Secure - -When you log in **with** ``persist=true``, you get a persistent cookie, -which means it has *some* expiration date. In production this is -currently 56 days (again, configurable, check current config in -`hegemony `__) and can be renewed -during token refresh. Example **persistent cookie**: - -:: - - POST /login?persist=true - - Set-Cookie: zuid=(redacted); Path=/access; Expires=Thu, 10-Jan-2019 10:43:28 GMT; Domain=zinfra.io; HttpOnly; Secure - -Token Refresh - ``POST /access`` --------------------------------- - -Since access tokens have a relatively short lifetime to limit the time -window of abuse for a captured token, they need to be regularly -refreshed. In order to refresh an access token, send a ``POST`` reques -to ``/access``, including the access cookie in the ``Cookie`` header and -the old (possibly expired) access token in the ``Authorization`` header: - -:: - - POST /access HTTP/1.1 - Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... - Cookie: zuid=... - [other headers omitted] - - - -Providing the old access token is not required but strongly recommended -as it will link the new access token to the old, enabling the API to see -the new access token as a continued session of the same client. - -As part of an access token refresh, the response may also contain a new -``zuid`` access cookie in form of a ``Set-Cookie`` header. A client must -expect a new ``zuid`` cookie as part of any access token refresh and -replace the existing cookie appropriately. - - - -.. _cookies: - -Cookie Management ------------------ - - - -.. _cookies-logout: - -Logout - ``POST /access/logout`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An explicit logout effectively deletes the cookie used to perform the -operation: - -:: - - POST /access/logout HTTP/1.1 - Authorization: Bearer fmmLpDSjArpksFv57r5rDrzZZlj... - Cookie: zuid=... - [other headers omitted] - - - -Afterwards, the cookie that was sent as part of the ``Cookie`` header is -no longer valid. - -If a client offers an explicit logout, this operation must be performed. -An explicit logout is especially important for Web clients. - - - -.. _cookies-labels: - -Labels -~~~~~~ - -Cookies can be labeled by specifying a ``label`` during login or -registration, e.g.: - -:: - - POST /login?persist=true HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890", - "code": "123456", - "label": "Google Nexus 5" - } - -Specifying a label is recommended as it helps to identify the cookies in -a user-friendly way and allows `selective -revocation <#cookies-revoke>`__ based on the labels. - - - -.. _cookies-list: - -Listing Cookies - ``GET /cookies`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To list the cookies currently associated with an account, send a ``GET`` -request to ``/cookies``. The response will contain a list of cookies, -e.g.: - -:: - - HTTP/1.1 200 OK - [other headers omitted] - - { - "cookies": [ - { - "time": "2015-06-04T14:29:23.000Z", - "id": 967153183, - "type": "session", - "label": null - }, - { - "time": "2015-06-04T14:44:23.000Z", - "id": 942451749, - "type": "session", - "label": null - }, - ... - ] - } - -Note that expired cookies are not automatically removed when they -expire, only as new cookies are issued. - - - -.. _cookies-revoke: - -Revoking Cookies - ``POST /cookies/remove`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Cookies can be removed individually or in bulk either by specifying the -full cookie structure as it is returned by `GET -/cookies <#cookies-list>`__ or only by their labels in a ``POST`` -request to ``/cookies/remove``, alongside with the user's credentials: - -:: - - POST /cookies/remove HTTP/1.1 - [headers omitted] - - { - "ids": [{}, {}, ...], - "labels": ["", "", ...] - "email": "me@wire.com", - "password": "secret" - } - -Cookie removal currently requires an account with an email address and -password. - - - -.. _password-reset: - -Password Reset - ``POST /password-reset`` ------------------------------------------ - -A password reset can be used to set a new password if the existing -password associated with an account has been forgotten. This is not to -be confused with the act of merely `changing your -password `__ for the purpose of password -rotation or if you suspect your current password to be compromised. - -Initiate a Password Reset -~~~~~~~~~~~~~~~~~~~~~~~~~ - -To initiate a password reset, send a ``POST`` request to -``/password-reset``, specifying either a verified email address or phone -number for the account in question: - -:: - - POST /password-reset HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890" - } - -For a phone number, the ``phone`` field would be used instead. As a -result of a successful request, either a password reset key and code is -sent via email or a password reset code is sent via SMS, depending on -whether an email address or a phone number was provided. Password reset -emails will contain a link to the `wire.com `__ -website which will guide the user through the completion of the password -reset, which means that the website will perform the necessary requests -to complete the password reset. To complete a password reset initiated -with a phone number, the completion of the password reset has to happen -from the mobile client application itself. - -Once a password reset has been initiated for an email address or phone -number, no further password reset can be initiated for the same email -address or phone number before the prior reset is completed or times -out. The current timeout for an initiated password reset is -``10 minutes``. - -Complete a Password Reset -~~~~~~~~~~~~~~~~~~~~~~~~~ - -To complete a password reset, the password reset code, together with the -new password and the ``email`` or ``phone`` used when initiating the -reset (or the opaque ``key`` sent by mail) are sent to -``/password-reset/complete`` in a ``POST`` request: - -:: - - POST /password-reset/complete HTTP/1.1 - [headers omitted] - - { - "phone": "+1234567890", - "code": "123456", - "password": "new-secret-password" - } - -There is a maximum of ``3`` attempts at completing a password reset, -after which the password reset code becomes invalid and a new password -reset must be initiated. - -A completed password reset results in all access cookies to be revoked, -requiring the user to `login <#login>`__. - -Related topics: SSO, Legalhold -------------------------------- - -.. _sso: - -Single Sign-On -~~~~~~~~~~~~~~~~~~ - -Users that are part of a team, for which a team admin has configured SSO (Single Sign On), authentication can happen through SAML. - -More information: - -* :ref:`FAQ ` -* `setup howtos for various IdP vendors `__ -* `a few fragments that may help admins `__ - - -LegalHold -~~~~~~~~~~ - -Users that are part of a team, for which a team admin has configured "LegalHold", can add a so-called "LegalHold" device. The endpoints in use to authenticate for a "LegalHold" Device are the same as for regular users, but the access tokens they get can only use a restricted set of API endpoints. See also `legalhold documentation on wire-server `__ diff --git a/docs/src/understand/api-client-perspective/index.md b/docs/src/understand/api-client-perspective/index.md new file mode 100644 index 0000000000..8bf19e4290 --- /dev/null +++ b/docs/src/understand/api-client-perspective/index.md @@ -0,0 +1,15 @@ +# Wire-server API documentation + +The following documentation provides information for, and takes the perspective of a Wire client developer. (wire-desktop, wire-android and wire-ios are examples of Wire Clients). This means only publicly accessible endpoints are mentioned. + +```{warning} +This section of the documentation is very incomplete at the time of writing (summer 2020) - more pages on the client API will follow in the future. +``` + +```{toctree} +:glob: true +:maxdepth: 2 +:titlesonly: true + +* +``` diff --git a/docs/src/understand/api-client-perspective/index.rst b/docs/src/understand/api-client-perspective/index.rst deleted file mode 100644 index d419508892..0000000000 --- a/docs/src/understand/api-client-perspective/index.rst +++ /dev/null @@ -1,14 +0,0 @@ -Wire-server API documentation -============================= - -The following documentation provides information for, and takes the perspective of a Wire client developer. (wire-desktop, wire-android and wire-ios are examples of Wire Clients). This means only publicly accessible endpoints are mentioned. - -.. warning:: - This section of the documentation is very incomplete at the time of writing (summer 2020) - more pages on the client API will follow in the future. - -.. toctree:: - :maxdepth: 2 - :glob: - :titlesonly: - - * diff --git a/docs/src/understand/api-client-perspective/swagger.rst b/docs/src/understand/api-client-perspective/swagger.md similarity index 67% rename from docs/src/understand/api-client-perspective/swagger.rst rename to docs/src/understand/api-client-perspective/swagger.md index 5dd3d29e36..057d5beb2a 100644 --- a/docs/src/understand/api-client-perspective/swagger.rst +++ b/docs/src/understand/api-client-perspective/swagger.md @@ -1,5 +1,4 @@ -Swagger API documentation (all public endpoints) -================================================ +# Swagger API documentation (all public endpoints) Our staging system provides swagger documentation of our public rest API. @@ -11,21 +10,19 @@ documentation still has some endpoints, but the new one is getting more and more Please check the new docs first, and if you can't find what you're looking for, double-check the old. -New docs --------- +## New docs These docs show swagger 2.0: -`new staging swagger page `_ +[new staging swagger page](https://staging-nginz-https.zinfra.io/api/swagger-ui/) - -Old docs --------- +## Old docs Some endpoints are only shown using swagger 1.2. At the time of writing, both swagger version 1.2 and version 2.0 are in use. If you are an employee of Wire, you can log in here and try out requests in the browser; if not, you can make use of the "List Operations" button on both 1.2 and 2.0 pages to see the possible API requests. -Browse to our `old staging swagger page `_ to see rendered swagger documentation for the remaining endpoints. +Browse to our [old staging swagger page](https://staging-nginz-https.zinfra.io/swagger-ui/) to see rendered swagger documentation for the remaining endpoints. -.. image:: img/swagger.png +```{image} img/swagger.png +``` diff --git a/docs/src/understand/federation/index.md b/docs/src/understand/federation/index.md new file mode 100644 index 0000000000..48e78ea649 --- /dev/null +++ b/docs/src/understand/federation/index.md @@ -0,0 +1,24 @@ +(federation-understand)= + +# Wire Federation + +Wire Federation, once implemented, aims to allow multiple Wire-server {ref}`backends ` to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. + +```{note} +Federation is as of January 2022 still work in progress, since the implementation of federation is ongoing, and certain design decision are still subject to change. Where possible documentation will indicate the state of implementation. + +Some sections of the documentation are still incomplete (indicated with a 'TODO' comment). Check back later for updates. +``` + +% comment: The toctree directive below takes a list of the pages you want to appear in order, +% and '*' is used to include any other pages in the federation directory in alphabetical order + +```{toctree} +:glob: true +:maxdepth: 2 +:numbered: true + +introduction +architecture +* +``` diff --git a/docs/src/understand/federation/index.rst b/docs/src/understand/federation/index.rst deleted file mode 100644 index 70ff484f1f..0000000000 --- a/docs/src/understand/federation/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. _federation-understand: - -+++++++++++++++++ -Wire Federation -+++++++++++++++++ - -Wire Federation, once implemented, aims to allow multiple Wire-server :ref:`backends ` to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. - -.. note:: - Federation is as of January 2022 still work in progress, since the implementation of federation is ongoing, and certain design decision are still subject to change. Where possible documentation will indicate the state of implementation. - - Some sections of the documentation are still incomplete (indicated with a 'TODO' comment). Check back later for updates. - -.. - comment: The toctree directive below takes a list of the pages you want to appear in order, - and '*' is used to include any other pages in the federation directory in alphabetical order - -.. toctree:: - :maxdepth: 2 - :numbered: - :glob: - - introduction - architecture - * diff --git a/docs/src/understand/helm.md b/docs/src/understand/helm.md new file mode 100644 index 0000000000..9b27659a75 --- /dev/null +++ b/docs/src/understand/helm.md @@ -0,0 +1,61 @@ +(understand-helm)= + +# Understanding helm + +See also the official [helm documentation](https://docs.helm.sh/). This page is meant to explain a few concepts directly relevant when installing wire-server helm charts. + +(understand-helm-overrides)= + +## Overriding helm configuration settings + +### Default values + +Default values are under a specific chart's `values.yaml` file, e.g. for the chart named `cassandra-ephemeral`, this file: [charts/cassandra-ephemeral/values.yaml](https://github.com/wireapp/wire-server/blob/develop/charts/cassandra-ephemeral/values.yaml). When you install or upgrade a chart, with e.g.: + +``` +helm upgrade --install my-cassandra wire/cassandra-ephemeral +``` + +Then the default values from above are used. + +### Overriding + +Overriding parts of the yaml configuration can be achieved by passing `-f path/to/override-file.yaml` when installing or upgrading a helm chart, like this: + +Create file my-file.yaml: + +```yaml +cassandra-ephemeral: + resources: + requests: + cpu: "2" +``` + +Now you can install that chart with a custom value (using 2 cpu cores): + +``` +helm upgrade --install my-cassandra wire/cassandra-ephemeral -f my-values.yaml +``` + +### Sub charts + +If a chart uses sub charts, there can be overrides in the parent +chart's `values.yaml` file, if namespaced to the sub chart. +Example: if chart `parent` includes chart `child`, and +`child`'s `values.yaml` has a default value `foo: bar`, and the +`parent` chart's `values.yaml` has a value + +```yaml +child: + foo: baz +``` + +then the value that will be used for `foo` by default is `baz` when you install the parent chart. + +Note that if you `helm install parent` but wish to override values for `child`, you need to pass them as above, indented underneath `child:` as above. + +### Multiple overrides + +If `-f ` is used multiple times, the last file wins in case keys exist +multiple times (there is no merge performed between multiple files passed to `-f`). +This can lead to unexpected results. If you use multiple files with `-f`, ensure they don't overlap. diff --git a/docs/src/understand/helm.rst b/docs/src/understand/helm.rst deleted file mode 100644 index 3899186182..0000000000 --- a/docs/src/understand/helm.rst +++ /dev/null @@ -1,64 +0,0 @@ -.. _understand-helm: - -Understanding helm -=================== - -See also the official `helm documentation `__. This page is meant to explain a few concepts directly relevant when installing wire-server helm charts. - - -.. _understand-helm-overrides: - -Overriding helm configuration settings ------------------------------------------- - -Default values -^^^^^^^^^^^^^^ - -Default values are under a specific chart's ``values.yaml`` file, e.g. for the chart named ``cassandra-ephemeral``, this file: `charts/cassandra-ephemeral/values.yaml `__. When you install or upgrade a chart, with e.g.:: - - helm upgrade --install my-cassandra wire/cassandra-ephemeral - -Then the default values from above are used. - -Overriding -^^^^^^^^^^^ - -Overriding parts of the yaml configuration can be achieved by passing ``-f path/to/override-file.yaml`` when installing or upgrading a helm chart, like this: - -Create file my-file.yaml: - -.. code:: yaml - - cassandra-ephemeral: - resources: - requests: - cpu: "2" - -Now you can install that chart with a custom value (using 2 cpu cores):: - - helm upgrade --install my-cassandra wire/cassandra-ephemeral -f my-values.yaml - -Sub charts -^^^^^^^^^^^ - -If a chart uses sub charts, there can be overrides in the parent -chart's ``values.yaml`` file, if namespaced to the sub chart. -Example: if chart ``parent`` includes chart ``child``, and -``child``'s ``values.yaml`` has a default value ``foo: bar``, and the -``parent`` chart's ``values.yaml`` has a value - -.. code:: yaml - - child: - foo: baz - -then the value that will be used for ``foo`` by default is ``baz`` when you install the parent chart. - -Note that if you ``helm install parent`` but wish to override values for ``child``, you need to pass them as above, indented underneath ``child:`` as above. - -Multiple overrides -^^^^^^^^^^^^^^^^^^^^ - -If ``-f `` is used multiple times, the last file wins in case keys exist -multiple times (there is no merge performed between multiple files passed to `-f`). -This can lead to unexpected results. If you use multiple files with `-f`, ensure they don't overlap. diff --git a/docs/src/understand/index.md b/docs/src/understand/index.md new file mode 100644 index 0000000000..f7ca56369a --- /dev/null +++ b/docs/src/understand/index.md @@ -0,0 +1,17 @@ +(understand)= + +# Understanding wire-server components + +This section is almost empty, more documentation will come soon... + +```{toctree} +:glob: true +:maxdepth: 1 + +Overview +Audio/video calling, restund servers (TURN/STUN) +Conference Calling 2.0 (SFT) +Minio +Helm +Federation +``` diff --git a/docs/src/understand/index.rst b/docs/src/understand/index.rst deleted file mode 100644 index 3cca9519a8..0000000000 --- a/docs/src/understand/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. _understand: - -Understanding wire-server components -==================================== - -This section is almost empty, more documentation will come soon... - -.. toctree:: - :maxdepth: 1 - :glob: - - Overview - Audio/video calling, restund servers (TURN/STUN) - Conference Calling 2.0 (SFT) - Minio - Helm - Federation diff --git a/docs/src/understand/minio.rst b/docs/src/understand/minio.md similarity index 86% rename from docs/src/understand/minio.rst rename to docs/src/understand/minio.md index 0c8fb60c38..afd4e1cd27 100644 --- a/docs/src/understand/minio.rst +++ b/docs/src/understand/minio.md @@ -1,10 +1,8 @@ -Minio -====== +# Minio -Official minio documentation available: ``_ +Official minio documentation available: [https://docs.min.io/](https://docs.min.io/) -Minio philosophy ------------------ +## Minio philosophy Minio clusters are configured with a fixed size once, and cannot be resized afterwards. It is thus important to make a good conservative estimate about @@ -23,8 +21,7 @@ cluster is starting to get full, you will need to set up a parallel bigger cluster, mirror everything to the new cluster, swap the DNS entries to the new one, and then decommission the old one. -Hurdles from the trenches: disk usage statistics; directories vs. disks ------------------------------------------------------------------------ +## Hurdles from the trenches: disk usage statistics; directories vs. disks I have done some more go code reading and have solved more minio mysteries. tl;dr: if you want to be safe, run minio on disks, not @@ -35,7 +32,7 @@ to figure out the amount of available blocks. If it's not a mount directory, it will just call `du .` in a for loop and update some counter (which sounds like a bad strategy to me). -https://github.com/minio/minio/blob/e6d8e272ced8b54872c6df1ef2ad556092280224/cmd/posix.go#L320-L352 + so the answer is: if you use minio, e.g. with mountpoints, it will silently do the right thing and if you configure it to use two directories on the same diff --git a/docs/src/understand/notes/port-ranges.md b/docs/src/understand/notes/port-ranges.md new file mode 100644 index 0000000000..94191336da --- /dev/null +++ b/docs/src/understand/notes/port-ranges.md @@ -0,0 +1,36 @@ +--- +orphan: true +--- + +(port-ranges)= + +# Note on port ranges + +Some parts of Wire (SFT, Restund) related to conference calling and Audio/Video, establish outgoing connections in a range of UDP ports. Which ports are used is determined by the kernel using `/proc/sys/net/ipv4/ip_local_port_range`. + +The /proc/sys/net/ipv4/ip_local_port_range defines the local port range that is used by TCP and UDP traffic to choose the local port. + +You will see in the parameters of this file two numbers: The first number is the first local port allowed for TCP and UDP traffic on the server, the second is the last local port number. + +When setting up firewall rules, this entire range must be allowed for both UDP and TCP. + +This range is defined by the system, and is set by the `/proc/sys/net/ipv4/ip_local_port_range` parameter. + +You read this range for your system by running the following command: + +```bash +cat /proc/sys/net/ipv4/ip_local_port_range +``` + +Or by finding the following line in your `/etc/sysctl.conf` file, if it exists: + +``` +# Allowed local port range +net.ipv4.ip_local_port_range = 32768 61000 +``` + +To change the range, edit the `/etc/sysctl.conf` file or run the following command: + +```bash +echo "32768 61001" > /proc/sys/net/ipv4/ip_local_port_range +``` diff --git a/docs/src/understand/notes/port-ranges.rst b/docs/src/understand/notes/port-ranges.rst deleted file mode 100644 index 0d2cc4e13b..0000000000 --- a/docs/src/understand/notes/port-ranges.rst +++ /dev/null @@ -1,36 +0,0 @@ -:orphan: - -.. _port-ranges: - -Note on port ranges -=================== - -Some parts of Wire (SFT, Restund) related to conference calling and Audio/Video, establish outgoing connections in a range of UDP ports. Which ports are used is determined by the kernel using ``/proc/sys/net/ipv4/ip_local_port_range``. - -The /proc/sys/net/ipv4/ip_local_port_range defines the local port range that is used by TCP and UDP traffic to choose the local port. - -You will see in the parameters of this file two numbers: The first number is the first local port allowed for TCP and UDP traffic on the server, the second is the last local port number. - -When setting up firewall rules, this entire range must be allowed for both UDP and TCP. - -This range is defined by the system, and is set by the ``/proc/sys/net/ipv4/ip_local_port_range`` parameter. - -You read this range for your system by running the following command: - -.. code-block:: bash - - cat /proc/sys/net/ipv4/ip_local_port_range - -Or by finding the following line in your ``/etc/sysctl.conf`` file, if it exists: - -.. code-block:: - - # Allowed local port range - net.ipv4.ip_local_port_range = 32768 61000 - -To change the range, edit the ``/etc/sysctl.conf`` file or run the following command: - -.. code-block:: bash - - echo "32768 61001" > /proc/sys/net/ipv4/ip_local_port_range - diff --git a/docs/src/understand/overview.md b/docs/src/understand/overview.md new file mode 100644 index 0000000000..56f203f707 --- /dev/null +++ b/docs/src/understand/overview.md @@ -0,0 +1,143 @@ +(overview)= + +# Overview + +## Introduction + +In a simplified way, the server components for Wire involve the following: + +```{image} img/architecture-server-simplified.png +``` + +The Wire clients (such as the Wire app on your phone) connect either directly (or via a load balancer) to the "Wire Server". By "Wire Server" we mean multiple API server components that connect to each other, and which also connect to a few databases. Both the API components and the databases are each in a "cluster", which means copies of the same program code runs multiple times. This allows any one component to fail without users noticing that there is a problem (also called +"high-availability"). + +## Architecture and networking + +Note that the webapp, account pages, and team-settings, while in a way not part of the backend, +are installed with the rest and therefore included. + +### Focus on internet protocols + +```{image} ./img/architecture-tls-on-prem-2020-09.png +``` + +### Focus on high-availability + +The following diagram shows a usual setup with multiple VMs (Virtual Machines): + +```{image} ../how-to/install/img/architecture-server-ha.png +``` + +Wire clients (such as the Wire app on your phone) connect to a load balancer. + +The load balancer forwards traffic to the ingress inside the kubernetes VMs. (Restund is special, see {ref}`understand-restund` for details on how Restund works.) + +The nginx ingress pods inside kubernetes look at incoming traffic, and forward that traffic on to the right place, depending on what's inside the URL passed. For example, if a request comes in for `https://example-https.example.com`, it is forwarded to a component called `nginz`, which is the main entry point for the [wire-server API](https://github.com/wireapp/wire-server). If, however, a request comes in for `https://webapp.example.com`, it is forwarded to a component called [webapp](https://github.com/wireapp/wire-webapp), which hosts the graphical browser Wire client (as found when you open [https://app.wire.com](https://app.wire.com)). + +Wire-server needs a range of databases. Their names are: cassandra, elasticsearch, minio, redis, etcd. + +All the server components on one physical machine can connect to all the databases (also those on a different physical machine). The databases each connect to each-other, e.g. cassandra on machine 1 will connect to the cassandra VMs on machines 2 and 3. + +### Backend components startup + +The Wire server backend is designed to run on a kubernetes cluster. From a high level perspective the startup sequence from machine power-on to the Wire server being ready to receive requests is as follow: + +1. *Kubernetes node power on*. Systemd starts the kubelet service which makes the worker node available to kubernetes. For more details about kubernetes startup refer to [the official kubernetes documentation](https://kubernetes.io/docs/reference/setup-tools/kubeadm/implementation-details/). For details about the installation and configuration of kubernetes and worker nodes for Wire server see {ref}`Installing kubernetes and databases on VMs with ansible ` +2. *Kubernetes workload startup*. Kubernetes will ensure that Wire server workloads installed via helm are scheduled on available worker nodes. For more details about workload scheduling refer to [the official kubernetes documentation](https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/). For details about how to install Wire server with helm refer to {ref}`Installing wire-server (production) components using Helm `. +3. *Stateful workload startup*. Systemd starts the stateful services (cassandra, elasticsearch and minio). See for instance [ansible-cassandra role](https://github.com/wireapp/ansible-cassandra/blob/master/tasks/systemd.yml#L10) and other database installation instructions in {ref}`Installing kubernetes and databases on VMs with ansible ` +4. *Other services*. Systemd starts the restund docker container. See [ansible-restund role](https://github.com/wireapp/ansible-restund/blob/9807313a7c72ffa40e74f69d239404fd87db65ab/templates/restund.service.j2#L12-L19). For details about docker container startup [consult the official documentation](https://docs.docker.com/get-started/overview/#docker-architecture) + +```{note} +For more information about Virual Machine startup or operating system level service startup, please consult your virtualisation and operating system documentation. +``` + +### Focus on pods + +The Wire backend runs in [a kubernetes cluster](https://kubernetes.io/), with different components running in different [pods](https://kubernetes.io/docs/concepts/workloads/pods/). + +This is a list of those pods as found in a typical installation. + +HTTPS Entry points: + +- `nginx-ingress-controller-controller`: [Ingress](https://kubernetes.github.io/ingress-nginx/) exposes HTTP and HTTPS routes from outside the cluster to services within the cluster. +- `nginx-ingress-controller-default-backend`: [The default backend](https://kubernetes.github.io/ingress-nginx/user-guide/default-backend/) is a service which handles all URL paths and hosts the nginx controller doesn't understand (i.e., all the requests that are not mapped with an Ingress), that is 404 pages. Part of `nginx-ingress`. + +Frontend pods: + +- `webapp`: The fully functioning Web client (like ). [This pod](https://github.com/wireapp/wire-docs/blob/master/src/how-to/install/helm.rst#what-will-be-installed) serves the web interface itself, which then interfaces with other services/pods, such as the APIs. +- `account-pages`: [This pod](https://github.com/wireapp/wire-docs/blob/master/src/how-to/install/helm.rst#what-will-be-installed) serves Web pages for user account management (a few pages relating to e.g. password reset). +- `team-settings`: Team management Web interface (like ). + +Pods with an HTTP API: + +- `brig`: [The user management API service](https://github.com/wireapp/wire-server/tree/develop/services/brig). Connects to `cassandra` and `elastisearch` for user data storage, sends emails and SMS for account validation. +- `cannon`: [WebSockets API Service](https://github.com/wireapp/wire-server/blob/develop/services/cannon/). Holds WebSocket connections. +- `cargohold`: [Asset Storage API Service](https://docs.wire.com/how-to/install/aws-prod.html). Amazon-AWS-S3-style services are used by `cargohold` to store encrypted files that users are sharing amongst each other, such as images, files, and other static content, which we call assets. All assets except profile pictures are symmetrically encrypted before storage (and the keys are only known to the participants of the conversation in which an assets was shared - servers have no knowledge of the keys). +- `galley`: [Conversations and Teams API Service](https://docs.wire.com/understand/api-client-perspective/index.html). Data is stored in cassandra. Uses `gundeck` to send notifications to users. +- `nginz`: Public API Reverse Proxy (Nginx with custom libzauth module). A modified copy of nginx, compiled with a specific set of upstream extra modules, and one important additional module zauth_nginx_module. Responsible for user authentication validation. Forwards traffic to all other API services (except federator) +- `spar`: [Single Sign On (SSO)](https://en.wikipedia.org/wiki/Single_sign-on) and [SCIM](https://en.wikipedia.org/wiki/System_for_Cross-domain_Identity_Management). Stores data in cassandra. +- `gundeck`: Push Notification Hub (WebSocket/mobile push notifications). Uses redis as a temporary data store for websocket presences. Uses Amazon SNS and SQS. +- `federator`: [Connects different wire installations together](https://docs.wire.com/understand/federation/index.html). Wire Federation, once implemented, aims to allow multiple Wire-server backends to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. + +Supporting pods and data storage: + +- `cassandra-ephemeral` (or `cassandra-external`): [NoSQL Database management system](https://github.com/wireapp/wire-server/tree/develop/charts/cassandra-ephemeral) (). Everything stateful in wire-server (cassandra is used by `brig`, `galley`, `gundeck` and `spar`) is stored in cassandra. + \* `cassandra-ephemeral` is for test clusters where persisting the data (i.e. loose users, conversations,...) does not matter, but this shouldn't be used in production environments. + \* `cassandra-external` is used to point to an external cassandra cluster which is installed outside of Kubernetes. +- `demo-smtp`: In "demo" installations, used to replace a proper external SMTP server for the sending of emails (for example verification codes). In production environments, an actual SMTP server is used directly instead of this pod. () +- `fluent-bit`: A log processor and forwarder, allowing collection of data such as metrics and logs from different sources. Not typically deployed. () +- `elastisearch-ephemeral` (or `elastisearch-external`): [Distributed search and analytics engines, stores some user information (name, handle, userid, teamid)](https://github.com/wireapp/wire-server/tree/develop/charts/elastisearch-external). Information is duplicated here from cassandra to allow searching for users. Information here can be re-populated from data in cassandra (albeit with some downtime for search functionality) (). + \* `elastisearch-ephemeral` is for test clusters where persisting the data doesn't matter. + \* `elastisearch-external` refers to elasticsearch IPs located outside kubernetes by specifying IPs manually. +- `fake-aws-s3`: Amazon-AWS-S3-compatible object storage using MinIO (), used by cargohold to store (encrypted) assets such as files, posted images, profile pics, etc. +- `fake-aws-s3-reaper`: Creates the default S3 bucket inside fake-aws-s3. +- `fake-aws-sns`. [Amazon Simple Notification Service (Amazon SNS)](https://docs.aws.amazon.com/AmazonS3/latest/userguide/NotificationHowTo.html), used to push messages to mobile devices or distributed services. SNS can publish a message once, and deliver it one or more times. +- `fake-aws-sqs`: [Amazon Simple Queue Service (Amazon SQS) queue](https://docs.aws.amazon.com/AmazonS3/latest/userguide/NotificationHowTo.html), used to transmit any volume of data without requiring other services to be always available. +- `redis-ephemeral`: Stores websocket connection assignments (part of the `gundeck` / `cannon` architecture). + +Short running jobs that run during installation/upgrade (these should usually be in the status 'Completed' except immediately after installation/upgrade): + +- `cassandra-migrations`: Used to initialize or upgrade the database schema in cassandra (for example when the software is upgraded to a new version). +- `galley-migrate-data`: Used to upgrade data in `cassandra` when the data model changes (for example when the software is upgraded to a new version). +- `brig-index-migrate-data`: Used to upgrade data in `cassandra` when the data model changes in brig (for example when the software is upgraded to a new version) +- `elastisearch-index-create`: [Creates](https://github.com/wireapp/wire-server/blob/develop/charts/elasticsearch-index/templates/create-index.yaml#L29) an Elastisearch index for brig. +- `spar-migrate-data`: [Used to update spar data](https://github.com/wireapp/wire-server/blob/develop/charts/cassandra-migrations/templates/spar-migrate-data.yaml) in cassandra when schema changes occur. + +As an example, this is the result of running the `kubectl get pods --namespace wire` command to obtain a list of all pods in a typical cluster: + +```shell +NAMESPACE NAME READY STATUS RESTARTS AGE +wire account-pages-54bfcb997f-hwxlf 1/1 Running 0 85d +wire brig-58bc7f844d-rp2mx 1/1 Running 0 3h54m +wire brig-index-migrate-data-s7lmf 0/1 Completed 0 3h33m +wire cannon-0 1/1 Running 0 3h53m +wire cargohold-779bff9fc6-7d9hm 1/1 Running 0 3h54m +wire cassandra-ephemeral-0 1/1 Running 0 176d +wire cassandra-migrations-66n8d 0/1 Completed 0 3h34m +wire demo-smtp-784ddf6989-7zvsk 1/1 Running 0 176d +wire elasticsearch-ephemeral-86f4b8ff6f-fkjlk 1/1 Running 0 176d +wire elasticsearch-index-create-l5zbr 0/1 Completed 0 3h34m +wire fake-aws-s3-77d9447b8f-9n4fj 1/1 Running 0 176d +wire fake-aws-s3-reaper-78d9f58dd4-kf582 1/1 Running 0 176d +wire fake-aws-sns-6c7c4b7479-nzfj2 2/2 Running 0 176d +wire fake-aws-sqs-59fbfbcbd4-ptcz6 2/2 Running 0 176d +wire federator-6d7b66f4d5-xgkst 1/1 Running 0 3h54m +wire galley-5b47f7ff96-m9zrs 1/1 Running 0 3h54m +wire galley-migrate-data-97gn8 0/1 Completed 0 3h33m +wire gundeck-76c4599845-4f4pd 1/1 Running 0 3h54m +wire nginx-ingress-controller-controller-2nbkq 1/1 Running 0 9d +wire nginx-ingress-controller-controller-8ggw2 1/1 Running 0 9d +wire nginx-ingress-controller-default-backend-dd5c45cf-jlmbl 1/1 Running 0 176d +wire nginz-77d7586bd9-vwlrh 2/2 Running 0 3h54m +wire redis-ephemeral-master-0 1/1 Running 0 176d +wire spar-8576b6845c-npb92 1/1 Running 0 3h54m +wire spar-migrate-data-lz5ls 0/1 Completed 0 3h33m +wire team-settings-86747b988b-5rt45 1/1 Running 0 50d +wire webapp-54458f756c-r7l6x 1/1 Running 0 3h54m + 1/1 Running 0 3h54m +``` + +```{note} +This list is not exhaustive, and your installation may have additional pods running depending on your configuration. +``` diff --git a/docs/src/understand/overview.rst b/docs/src/understand/overview.rst deleted file mode 100644 index 71d2f2a45d..0000000000 --- a/docs/src/understand/overview.rst +++ /dev/null @@ -1,148 +0,0 @@ -Overview -======== - -Introduction ------------- - -In a simplified way, the server components for Wire involve the following: - -|arch-simplified| - -The Wire clients (such as the Wire app on your phone) connect either directly (or via a load balancer) to the "Wire Server". By "Wire Server" we mean multiple API server components that connect to each other, and which also connect to a few databases. Both the API components and the databases are each in a "cluster", which means copies of the same program code runs multiple times. This allows any one component to fail without users noticing that there is a problem (also called -"high-availability"). - -Architecture and networking ----------------------------- - -Note that the webapp, account pages, and team-settings, while in a way not part of the backend, -are installed with the rest and therefore included. - -Focus on internet protocols -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -|arch-proto| - - -Focus on high-availability -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The following diagram shows a usual setup with multiple VMs (Virtual Machines): - -|arch-ha| - -Wire clients (such as the Wire app on your phone) connect to a load balancer. - -The load balancer forwards traffic to the ingress inside the kubernetes VMs. (Restund is special, see :ref:`understand-restund` for details on how Restund works.) - -The nginx ingress pods inside kubernetes look at incoming traffic, and forward that traffic on to the right place, depending on what's inside the URL passed. For example, if a request comes in for ``https://example-https.example.com``, it is forwarded to a component called ``nginz``, which is the main entry point for the `wire-server API `__. If, however, a request comes in for ``https://webapp.example.com``, it is forwarded to a component called `webapp `__, which hosts the graphical browser Wire client (as found when you open ``__). - -Wire-server needs a range of databases. Their names are: cassandra, elasticsearch, minio, redis, etcd. - -All the server components on one physical machine can connect to all the databases (also those on a different physical machine). The databases each connect to each-other, e.g. cassandra on machine 1 will connect to the cassandra VMs on machines 2 and 3. - -Backend components startup -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The Wire server backend is designed to run on a kubernetes cluster. From a high level perspective the startup sequence from machine power-on to the Wire server being ready to receive requests is as follow: - -1. *Kubernetes node power on*. Systemd starts the kubelet service which makes the worker node available to kubernetes. For more details about kubernetes startup refer to `the official kubernetes documentation `__. For details about the installation and configuration of kubernetes and worker nodes for Wire server see :ref:`Installing kubernetes and databases on VMs with ansible ` -2. *Kubernetes workload startup*. Kubernetes will ensure that Wire server workloads installed via helm are scheduled on available worker nodes. For more details about workload scheduling refer to `the official kubernetes documentation `__. For details about how to install Wire server with helm refer to :ref:`Installing wire-server (production) components using Helm `. -3. *Stateful workload startup*. Systemd starts the stateful services (cassandra, elasticsearch and minio). See for instance `ansible-cassandra role `__ and other database installation instructions in :ref:`Installing kubernetes and databases on VMs with ansible ` -4. *Other services*. Systemd starts the restund docker container. See `ansible-restund role `__. For details about docker container startup `consult the official documentation `__ - -.. note:: - For more information about Virual Machine startup or operating system level service startup, please consult your virtualisation and operating system documentation. - -.. |arch-simplified| image:: img/architecture-server-simplified.png -.. |arch-proto| image:: ./img/architecture-tls-on-prem-2020-09.png -.. |arch-ha| image:: ../how-to/install/img/architecture-server-ha.png - -Focus on pods -~~~~~~~~~~~~~ - -The Wire backend runs in `a kubernetes cluster `__, with different components running in different `pods `__. - -This is a list of those pods as found in a typical installation. - -HTTPS Entry points: - -* ``nginx-ingress-controller-controller``: `Ingress `__ exposes HTTP and HTTPS routes from outside the cluster to services within the cluster. -* ``nginx-ingress-controller-default-backend``: `The default backend `__ is a service which handles all URL paths and hosts the nginx controller doesn't understand (i.e., all the requests that are not mapped with an Ingress), that is 404 pages. Part of ``nginx-ingress``. - -Frontend pods: - -* ``webapp``: The fully functioning Web client (like https://app.wire.com). `This pod `__ serves the web interface itself, which then interfaces with other services/pods, such as the APIs. -* ``account-pages``: `This pod `__ serves Web pages for user account management (a few pages relating to e.g. password reset). -* ``team-settings``: Team management Web interface (like https://teams.wire.com). - -Pods with an HTTP API: - -* ``brig``: `The user management API service `__. Connects to ``cassandra`` and ``elastisearch`` for user data storage, sends emails and SMS for account validation. -* ``cannon``: `WebSockets API Service `__. Holds WebSocket connections. -* ``cargohold``: `Asset Storage API Service `__. Amazon-AWS-S3-style services are used by ``cargohold`` to store encrypted files that users are sharing amongst each other, such as images, files, and other static content, which we call assets. All assets except profile pictures are symmetrically encrypted before storage (and the keys are only known to the participants of the conversation in which an assets was shared - servers have no knowledge of the keys). -* ``galley``: `Conversations and Teams API Service `__. Data is stored in cassandra. Uses ``gundeck`` to send notifications to users. -* ``nginz``: Public API Reverse Proxy (Nginx with custom libzauth module). A modified copy of nginx, compiled with a specific set of upstream extra modules, and one important additional module zauth_nginx_module. Responsible for user authentication validation. Forwards traffic to all other API services (except federator) -* ``spar``: `Single Sign On (SSO) `__ and `SCIM `__. Stores data in cassandra. -* ``gundeck``: Push Notification Hub (WebSocket/mobile push notifications). Uses redis as a temporary data store for websocket presences. Uses Amazon SNS and SQS. -* ``federator``: `Connects different wire installations together `__. Wire Federation, once implemented, aims to allow multiple Wire-server backends to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. - -Supporting pods and data storage: - -* ``cassandra-ephemeral`` (or ``cassandra-external``): `NoSQL Database management system `__ (https://en.wikipedia.org/wiki/Apache_Cassandra). Everything stateful in wire-server (cassandra is used by ``brig``, ``galley``, ``gundeck`` and ``spar``) is stored in cassandra. - * ``cassandra-ephemeral`` is for test clusters where persisting the data (i.e. loose users, conversations,...) does not matter, but this shouldn't be used in production environments. - * ``cassandra-external`` is used to point to an external cassandra cluster which is installed outside of Kubernetes. -* ``demo-smtp``: In "demo" installations, used to replace a proper external SMTP server for the sending of emails (for example verification codes). In production environments, an actual SMTP server is used directly instead of this pod. (https://github.com/namshi/docker-smtp) -* ``fluent-bit``: A log processor and forwarder, allowing collection of data such as metrics and logs from different sources. Not typically deployed. (https://fluentbit.io/) -* ``elastisearch-ephemeral`` (or ``elastisearch-external``): `Distributed search and analytics engines, stores some user information (name, handle, userid, teamid) `__. Information is duplicated here from cassandra to allow searching for users. Information here can be re-populated from data in cassandra (albeit with some downtime for search functionality) (https://www.elastic.co/what-is/elasticsearch). - * ``elastisearch-ephemeral`` is for test clusters where persisting the data doesn't matter. - * ``elastisearch-external`` refers to elasticsearch IPs located outside kubernetes by specifying IPs manually. -* ``fake-aws-s3``: Amazon-AWS-S3-compatible object storage using MinIO (https://min.io/), used by cargohold to store (encrypted) assets such as files, posted images, profile pics, etc. -* ``fake-aws-s3-reaper``: Creates the default S3 bucket inside fake-aws-s3. -* ``fake-aws-sns``. `Amazon Simple Notification Service (Amazon SNS) `__, used to push messages to mobile devices or distributed services. SNS can publish a message once, and deliver it one or more times. -* ``fake-aws-sqs``: `Amazon Simple Queue Service (Amazon SQS) queue `__, used to transmit any volume of data without requiring other services to be always available. -* ``redis-ephemeral``: Stores websocket connection assignments (part of the ``gundeck`` / ``cannon`` architecture). - -Short running jobs that run during installation/upgrade (these should usually be in the status 'Completed' except immediately after installation/upgrade): - -* ``cassandra-migrations``: Used to initialize or upgrade the database schema in cassandra (for example when the software is upgraded to a new version). -* ``galley-migrate-data``: Used to upgrade data in ``cassandra`` when the data model changes (for example when the software is upgraded to a new version). -* ``brig-index-migrate-data``: Used to upgrade data in ``cassandra`` when the data model changes in brig (for example when the software is upgraded to a new version) -* ``elastisearch-index-create``: `Creates `__ an Elastisearch index for brig. -* ``spar-migrate-data``: `Used to update spar data `__ in cassandra when schema changes occur. - -As an example, this is the result of running the ``kubectl get pods --namespace wire`` command to obtain a list of all pods in a typical cluster: - -.. code:: shell - - NAMESPACE NAME READY STATUS RESTARTS AGE - wire account-pages-54bfcb997f-hwxlf 1/1 Running 0 85d - wire brig-58bc7f844d-rp2mx 1/1 Running 0 3h54m - wire brig-index-migrate-data-s7lmf 0/1 Completed 0 3h33m - wire cannon-0 1/1 Running 0 3h53m - wire cargohold-779bff9fc6-7d9hm 1/1 Running 0 3h54m - wire cassandra-ephemeral-0 1/1 Running 0 176d - wire cassandra-migrations-66n8d 0/1 Completed 0 3h34m - wire demo-smtp-784ddf6989-7zvsk 1/1 Running 0 176d - wire elasticsearch-ephemeral-86f4b8ff6f-fkjlk 1/1 Running 0 176d - wire elasticsearch-index-create-l5zbr 0/1 Completed 0 3h34m - wire fake-aws-s3-77d9447b8f-9n4fj 1/1 Running 0 176d - wire fake-aws-s3-reaper-78d9f58dd4-kf582 1/1 Running 0 176d - wire fake-aws-sns-6c7c4b7479-nzfj2 2/2 Running 0 176d - wire fake-aws-sqs-59fbfbcbd4-ptcz6 2/2 Running 0 176d - wire federator-6d7b66f4d5-xgkst 1/1 Running 0 3h54m - wire galley-5b47f7ff96-m9zrs 1/1 Running 0 3h54m - wire galley-migrate-data-97gn8 0/1 Completed 0 3h33m - wire gundeck-76c4599845-4f4pd 1/1 Running 0 3h54m - wire nginx-ingress-controller-controller-2nbkq 1/1 Running 0 9d - wire nginx-ingress-controller-controller-8ggw2 1/1 Running 0 9d - wire nginx-ingress-controller-default-backend-dd5c45cf-jlmbl 1/1 Running 0 176d - wire nginz-77d7586bd9-vwlrh 2/2 Running 0 3h54m - wire redis-ephemeral-master-0 1/1 Running 0 176d - wire spar-8576b6845c-npb92 1/1 Running 0 3h54m - wire spar-migrate-data-lz5ls 0/1 Completed 0 3h33m - wire team-settings-86747b988b-5rt45 1/1 Running 0 50d - wire webapp-54458f756c-r7l6x 1/1 Running 0 3h54m - 1/1 Running 0 3h54m -.. note:: - - This list is not exhaustive, and your installation may have additional pods running depending on your configuration. diff --git a/docs/src/understand/restund.rst b/docs/src/understand/restund.md similarity index 61% rename from docs/src/understand/restund.rst rename to docs/src/understand/restund.md index 35014c28bf..0cb8dd6f6d 100644 --- a/docs/src/understand/restund.rst +++ b/docs/src/understand/restund.md @@ -1,25 +1,22 @@ -.. _understand-restund: +(understand-restund)= -Restund (TURN) servers -======================== +# Restund (TURN) servers -Introduction -~~~~~~~~~~~~ +## Introduction Restund servers allow two users on different networks (for example Alice who is in an office connected to an office router and Bob who is at home connected to a home router) to have a Wire audio or video call. More precisely: - Restund is a modular and flexible - `STUN `__ and - `TURN `__ - Server, with IPv4 and IPv6 support. +> Restund is a modular and flexible +> [STUN](https://en.wikipedia.org/wiki/STUN) and +> [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) +> Server, with IPv4 and IPv6 support. -.. _architecture-restund: +(architecture-restund)= -Architecture -~~~~~~~~~~~~ +## Architecture Since the restund servers help establishing a connection between two users, they need to be reachable by both of these users, which usually @@ -32,29 +29,28 @@ Restund instance may communicate with other Restund instances. You can either have restund servers directly exposed to the public internet: -|architecture-restund| +```{image} img/architecture-restund.png +``` Or you can have them reachable by fronting them with a firewall or load balancer machine that may have a different IP than the server where restund is installed: -|architecture-restund-lb| +```{image} img/architecture-restund-lb.png +``` -What is it used for -~~~~~~~~~~~~~~~~~~~ +## What is it used for Restund is used to assist in NAT-traversal. Its goal is to connect two clients who are (possibly both) behind NAT directly in a peer to peer fashion, for optimal call quality and lowest latency. - client A sends a UDP packet to Restund; which will get address-translated by the router. Restund then sends back to the client what the source IP and the source port was that Restund observed. If the client then communicates this to Client B, Client B will be able to send data to that IP,port pair over UDP if it does so quickly enough. Client A and B will then have a peer-to-peer leg. - This is not always possible (e.g. symmetric NAT makes this technique impossible, as the router will NAT a different source-port for each connection). In that case clients fall back to TURN, which asks Restund to @@ -63,17 +59,16 @@ allocate a relay address which relays packets between nodes A and B. Restund servers need to have a wide range of ports open to allocate such relay addresses. -Network -~~~~~~~ +## Network As briefly mentioned above, a TURN server functions as a bridge between networks. Networks which don't have a direct route defined between them, usually have distinct address blocks. Depending on the address block they are configured with - such block is either considered to be *public* or *private* -(aka special-purpose addresses `[RFC 6890] `__) +(aka special-purpose addresses [\[RFC 6890\]](https://tools.ietf.org/html/rfc6890)) -- `IPv4 private blocks `__ -- `IPv6 private blocks `__ +- [IPv4 private blocks](https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml) +- [IPv6 private blocks](https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml) In cases where a machine, that is hosting the TURN server, also connects to a *private* network in which other services are running, chances are @@ -81,56 +76,51 @@ that these services are being indirectly exposed through that TURN server. To prevent this kind of exposure, a TURN server has to be configured with an inclusive or exclusive list of address blocks to prevents undesired connections from being -established [1]_. At the moment (Feb. 2021), this functionality is not yet available +established [^footnote-1]. At the moment (Feb. 2021), this functionality is not yet available with *Restund* on the application-level. Instead, the system-level firewall capabilities -must be utilized. The `IP ranges `__ -mentioned in the article [1]_ should be blocked for egress and, depending on the scenario, -also for ingress traffic. Tools like ``iptables`` or ``ufw`` can be used to set this up. - -.. [1] `Details about CVE-2020-26262, bypass of Coturn's default access control protection `__ +must be utilized. The [IP ranges](https://www.rtcsec.com/post/2021/01/details-about-cve-2020-26262-bypass-of-coturns-default-access-control-protection/#further-concerns-what-else) +mentioned in the article [^footnote-1] should be blocked for egress and, depending on the scenario, +also for ingress traffic. Tools like `iptables` or `ufw` can be used to set this up. +[^footnote-1]: [Details about CVE-2020-26262, bypass of Coturn's default access control protection](https://www.rtcsec.com/post/2021/01/details-about-cve-2020-26262-bypass-of-coturns-default-access-control-protection/) -.. _understand-restund-protocal-and-ports: +(understand-restund-protocal-and-ports)= -Protocols and open ports -~~~~~~~~~~~~~~~~~~~~~~~~ +## Protocols and open ports Restund servers provide the best audio/video connections if end-user devices -can connect to them via UDP. +can connect to them via UDP. -In this case, a firewall (if any) needs to allow and/or forward the complete :ref:`default port range ` for incoming UDP traffic. +In this case, a firewall (if any) needs to allow and/or forward the complete {ref}`default port range ` for incoming UDP traffic. -Ports for allocations are allocated from the :ref:`default port range `, for more information on this port range, how to read and change it, and how to configure your firewall, see :ref:`this note `. +Ports for allocations are allocated from the {ref}`default port range `, for more information on this port range, how to read and change it, and how to configure your firewall, see {ref}`this note `. -In case e.g. office firewall rules disallow UDP traffic in this range, there is a possibility to use TCP instead, at the expense of call quality. +In case e.g. office firewall rules disallow UDP traffic in this range, there is a possibility to use TCP instead, at the expense of call quality. -Port ``3478`` is the default control port, +Port `3478` is the default control port, however one UDP port per active connection is required, so a whole port range must be available and reachable from the outside. -If *Conference Calling 2.0* (:ref:`SFT `) is enabled, a Restund instance, -additionally, must be allowed to communicate with ::ref:`SFT instances ` +If *Conference Calling 2.0* ({ref}`SFT `) is enabled, a Restund instance, +additionally, must be allowed to communicate with :{ref}`SFT instances ` on the same UDP ports mentioned above. In this scenario a Restund server becomes sort of a proxy for the client, if the client is not able to establish a media channel between itself and the SFT server. -*For more information, please refer to the source code of the Ansible role:* `restund `__. +*For more information, please refer to the source code of the Ansible role:* [restund](https://github.com/wireapp/ansible-restund/blob/master/tasks/firewall.yml). -Control ports -^^^^^^^^^^^^^ +### Control ports -Restund listens for control messages on port ``3478`` on both UDP and TCP. It -also can listen on port ``5349`` which uses TLS. One can reconfigure both ports. -For example, port ``5349`` can be reconfigured to be port ``443``; so that TURN +Restund listens for control messages on port `3478` on both UDP and TCP. It +also can listen on port `5349` which uses TLS. One can reconfigure both ports. +For example, port `5349` can be reconfigured to be port `443`; so that TURN traffic can not be distinguished from any other TLS traffic. This might help with overcoming certain firewall restrictions. You can instead use (if that's -easier with firewall rules) for example ports ``80`` and ``443`` (requires to +easier with firewall rules) for example ports `80` and `443` (requires to run restund as root) or do a redirect from a load balancer (if using one) to -redirect ``443 -> 5349`` and ``80 -> 3478``. +redirect `443 -> 5349` and `80 -> 3478`. - -Amount of users and file descriptors -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +## Amount of users and file descriptors Each allocation (active connection by one participant) requires 1 or 2 file descriptors, so ensure you increase your file descriptor limits in @@ -140,33 +130,27 @@ Currently one restund server can have a maximum of 64000 allocations. If you have more users than that in an active call, you need to deploy more restund servers. -Load balancing and high-availability -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +## Load balancing and high-availability Load balancing is not possible, since STUN/TURN is a stateful protocol, -so UDP packets addressed to ``restund server 1``, if by means of a load -balancer were to end up at ``restund server 2``, would get dropped, as +so UDP packets addressed to `restund server 1`, if by means of a load +balancer were to end up at `restund server 2`, would get dropped, as the second server doesn't know the source address. High-availability is nevertheless ensured by having and advertising more than one restund server. Instead of the load balancer, the clients will switch their server if it fails. -Discovery and establishing a call -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +## Discovery and establishing a call A simplified flow of how restund servers, along with the wire-server are used to establish a call: -|flow-restund| +```{image} img/flow-restund.png +``` -DNS -~~~ +## DNS Usually DNS records are used which point to the public IPs of the restund servers (or of the respective firewall or load balancer machines). These DNS names are then used when configuring wire-server. - -.. |architecture-restund| image:: img/architecture-restund.png -.. |architecture-restund-lb| image:: img/architecture-restund-lb.png -.. |flow-restund| image:: img/flow-restund.png diff --git a/docs/src/understand/sft.rst b/docs/src/understand/sft.md similarity index 77% rename from docs/src/understand/sft.rst rename to docs/src/understand/sft.md index aec41fe742..28f2c432d6 100644 --- a/docs/src/understand/sft.rst +++ b/docs/src/understand/sft.md @@ -1,82 +1,76 @@ -.. _understand-sft: +(understand-sft)= -Conference Calling 2.0 (aka SFT) -================================ +# Conference Calling 2.0 (aka SFT) -Background ----------- +## Background Previously, Wire group calls were implemented as a mesh, where each participant was connected to each other in a peer-to-peer fashion. This meant that a client would have to upload their video and audio feeds separately for each participant. This in practice meant that the amount of participants was limited by the upload bandwidth of the clients. -Wire now has a signalling-forwarding unit called `SFT `__ which allows clients to upload once and +Wire now has a signalling-forwarding unit called [SFT](https://github.com/wireapp/wire-avs-service) which allows clients to upload once and then the SFT fans it out to the other clients. Because connections are not end-to-end anymore now, dTLS encryption offered by WebRTC is not enough anymore as the encryption is terminated at the server-side. To avoid Wire from seeing the contents of calls SFT utilises WebRTC InsertibleStreams to encrypt the packets a second time with a group key that is not known to the server. With SFT it is thus possible to have conference calls with many participants without compromising end-to-end security. -.. note:: - We will describe conferencing first in a single domain in this section. - Conferencing in an environment with Federation is described in the - :ref:`federated conferencing` section. +```{note} +We will describe conferencing first in a single domain in this section. +Conferencing in an environment with Federation is described in the +{ref}`federated conferencing` section. +``` - -Architecture ------------- +## Architecture The following diagram is centered around SFT and its role within a calling setup. Restund is seen as a mere client proxy and its relation to and interaction with a client is explained -:ref:`here `. The diagram shows that a call resides on a single SFT instance +{ref}`here `. The diagram shows that a call resides on a single SFT instance and that the instance allocates at least one port for media transport per participant in the call. -.. figure:: img/architecture-sft.png - - SFT signaling, and media sending from the perspective of one caller +```{figure} img/architecture-sft.png +SFT signaling, and media sending from the perspective of one caller +``` - -Establishing a call -------------------- +## Establishing a call 1. *Client A* wants to initiate a call. It contacts all the known SFT servers via HTTPS. The SFT server that is quickest to respond is the one that will be used by the client. - (Request 1: ``CONFCONN``) + (Request 1: `CONFCONN`) 2. *Client A* gathers connection candidates (own public IP, public IP of the network the - client is in with the help of STUN, through TURN servers) [1]_ for the SFT server to + client is in with the help of STUN, through TURN servers) [^footnote-1] for the SFT server to establish a media connection to *Client A*. These information are then being send again - from *Client A* to the chosen SFT server via HTTPS request. (Request 2: ``SETUP``) + from *Client A* to the chosen SFT server via HTTPS request. (Request 2: `SETUP`) 3. The SFT server tests which of the connection candidates actually work. Meaning, it goes through all the candidates until one leads to a successful media connection between itself and *client A* -4. *Client A* sends an end-to-end encrypted message [2]_ ``CONFSTART`` to all members of chat, which contains +4. *Client A* sends an end-to-end encrypted message [^footnote-2] `CONFSTART` to all members of chat, which contains the URL of the SFT server that is being used for the call. 5. Any other client that wants to join the call, does 1. + 2. with the exception of **only** contacting one SFT server i.e. the one that *client A* chose and told all other - potential participants about via ``CONFSTART`` message + potential participants about via `CONFSTART` message At that point a media connection between *client A* and the SFT server has been established, and they continue talking to each other by using the data-channel, which uses the media connection (i.e. no more HTTPS at that point). There are just 2 HTTPS request/response sequences per participant. -.. [1] STUN & TURN are both part of a :ref:`Restund server ` -.. [2] This encrypted message is sent in the same conversation, hidden from user's view but - interpreted by user's clients. It is sent via backend servers and forwarded to other - conversation participants, not to or via SFT. +[^footnote-1]: STUN & TURN are both part of a {ref}`Restund server ` +[^footnote-2]: This encrypted message is sent in the same conversation, hidden from user's view but + interpreted by user's clients. It is sent via backend servers and forwarded to other + conversation participants, not to or via SFT. -Prerequisites -------------- +## Prerequisites For Conference Calling to function properly, clients need to be able to reach the HTTPS interface of the SFT server(s) - either directly or through a load balancer sitting in front of the servers. This is only needed for the call initiation/joining part. Additionally, for the media connection, clients and SFT servers should be able to reach each other -via UDP (see :ref:`Firewall rules `). +via UDP (see {ref}`Firewall rules `). If that is not possible, then at least SFT servers and Restund servers should be able to reach each other via UDP - and clients may connect via UDP and/or TCP to Restund servers -(see :ref:`Protocols and open ports `), which in +(see {ref}`Protocols and open ports `), which in turn will connect to the SFT server. In the unlikely scenario where no UDP is allowed whatsoever or SFT servers may not be able to reach the Restund servers that clients are using to make themselves reachable, an SFT server itself can @@ -90,19 +84,17 @@ Due to this `hostNetwork` limitation only one SFT instance can run per node so i As a rule of thumb you will need 1vCPU of compute per 50 participants. SFT will utilise multiple cores. You can use this rule of thumb to decide how many kubernetes nodes you need to provision. -For more information about capacity planning and networking please refer to the `technical documentation `__ +For more information about capacity planning and networking please refer to the [technical documentation](https://github.com/wireapp/wire-server/blob/eab0ce1ff335889bc5a187c51872dfd0e78cc22b/charts/sftd/README.md) -.. _federated-sft: +(federated-sft)= -Federated Conference Calling -============================ +# Federated Conference Calling -Conferencing in a federated environment assumes that each domain participating in a +Conferencing in a federated environment assumes that each domain participating in a conference will use an SFT in its own domain. The SFT in the caller's domain is called -the `anchor SFT`. +the `anchor SFT`. -Multi-SFT Architecture ----------------------- +## Multi-SFT Architecture With support for federation, each domain participating in a conference is responsible to make available an SFT for users in that domain. The SFT in the domain of the caller is @@ -116,7 +108,7 @@ initiates a call in a federated conversation which contains herself, Adam also i A, and Bob and Beth in domain B. Alice's client first creates a conference and is assigned a conference URL on SFT A2. Because the SFT is configured for federation, it assumes the role of anchor and also returns an IP address and port (the `anchor SFT tuple`) -which can be used by any federated SFTs which need to connect. (Alice sets up her media +which can be used by any federated SFTs which need to connect. (Alice sets up her media connection with SFT A2 as normal). Alice's client forwards the conference URL and the anchor SFT tuple to the other @@ -128,9 +120,9 @@ to the anchor SFT using the anchor SFT tuple and provides the SFT URL. (Bob's cl also sets up media with SFT B1 normally.) At this point all paths are established and the conference call can happen normally. -.. figure:: img/multi-sft-noturn.png - - Basic Multi-SFT conference initiated by Alice in domain A, with Bob in domain B +```{figure} img/multi-sft-noturn.png +Basic Multi-SFT conference initiated by Alice in domain A, with Bob in domain B +``` Because some customers do not wish to expose their SFTs directly to hosts on the public Internet, the SFTs can allocate a port on a TURN server. In this way, only the IP @@ -140,16 +132,16 @@ this scenario. In this configuration, SFT A2 requests an allocation from the fe TURN server in domain A before responding to Alice. The anchor SFT tuple is the address allocated on the federation TURN server in domain A. -.. figure:: img/multi-sft-turn.png - - Multi-SFT conference with TURN servers between federated SFTs +```{figure} img/multi-sft-turn.png +Multi-SFT conference with TURN servers between federated SFTs +``` Finally, for extremely restrictive firewall environments, the TURN servers used for federated SFT traffic can be further secured with a TURN to TURN mutually authenticated DTLS connection. The SFTs allocate a channel inside this DTLS connection per conference. The channel number is included along with the anchor SFT tuple returned to Alice, which Alice shares with the conversation, which Bob sends to SFT B1, -and which SFT B1 uses when forming its DTLS connection to SFT A2. This DTLS connection +and which SFT B1 uses when forming its DTLS connection to SFT A2. This DTLS connection runs on a dedicated port number which is not used for regular TURN traffic. Under this configuration, only that single IP address and port is exposed for each federated TURN server with all SFT traffic multiplexed over the connection. The diagram below shows @@ -157,7 +149,6 @@ this scenario. Note that this TURN DTLS multiplexing is only used for SFT to SF communication into federated group calls, and does not affect the connectivity requirements for normal one-on-one calls. -.. figure:: img/multi-sft-turn-dtls.png - - Multi-SFT conference with federated TURN servers with DTLS multiplexing - +```{figure} img/multi-sft-turn-dtls.png +Multi-SFT conference with federated TURN servers with DTLS multiplexing +``` diff --git a/docs/src/understand/single-sign-on/design.rst b/docs/src/understand/single-sign-on/design.rst deleted file mode 100644 index af2102e363..0000000000 --- a/docs/src/understand/single-sign-on/design.rst +++ /dev/null @@ -1,3 +0,0 @@ -:orphan: - -This page is gone. Please visit `this one <./main.html>`_ diff --git a/docs/src/understand/single-sign-on/main.rst b/docs/src/understand/single-sign-on/main.rst deleted file mode 100644 index 8603a8fd71..0000000000 --- a/docs/src/understand/single-sign-on/main.rst +++ /dev/null @@ -1,560 +0,0 @@ - -Single sign-on and user provisioning ------------------------------------- - -.. contents:: - -Introduction -~~~~~~~~~~~~ - -This page is intended as a manual for administrator users in need of setting up :term:`SSO` and provisionning users using :term:`SCIM` on their installation of Wire. - -Historically and by default, Wire's user authentication method is via phone or password. This has security implications and does not scale. - -Solution: :term:`SSO` with :term:`SAML`! `(Security Assertion Markup Language) `_ - -:term:`SSO` systems allow users to identify on multiple systems (including Wire once configured as such) using a single ID and password. - -You can find some of the advantages of :term:`SSO` over more traditional schemes `here `_. - -Also historically, wire has allowed team admins and owners to manage their users in the team management app. - -This does not scale as it requires a lot of manual labor for each user. - -The solution we offer to solve this issue is implementing :term:`SCIM` `(System for Cross-domain Identity Management) `_ - -:term:`SCIM` is an interface that allows both software (for example Active Directory) and custom scripts to manage Identities (users) in bulk. - -This page explains how to set up :term:`SCIM` and then use it. - -.. note:: - Note that it is recommended to use both :term:`SSO` and :term:`SCIM` (as opposed to just :term:`SSO` alone). - The reason is if you only use :term:`SSO`, but do not configure/implement :term:`SCIM`, you will experience reduced functionality. - In particular, without :term:`SCIM` all Wire users will be named according their e-mail address and won't have any rich profiles. - See below in the :term:`SCIM` section for a more detailled explanation. - - -Further reading -~~~~~~~~~~~~~~~ - -If you can't find the answers to your questions here, we have a few -more documents. Some of them are very technical, some may not be up -to date any more, and we are planning to move many of them into this -page. But for now they may be worth checking out. - -- :ref:`Trouble shooting & FAQ ` -- https://support.wire.com/hc/en-us/sections/360000580658-Authentication -- https://github.com/wireapp/wire-server/blob/1753b790e5cfb2d35e857648c88bcad3ac329f01/docs/reference/spar-braindump.md -- https://github.com/wireapp/wire-server/tree/1753b790e5cfb2d35e857648c88bcad3ac329f01/docs/reference/provisioning/ - - -Definitions -~~~~~~~~~~~ - -The following concepts need to be understood to use the present manual: - -.. glossary:: - - SCIM - System for Cross-domain Identity Management (:term:`SCIM`) is a standard for automating the exchange of user identity information between identity domains, or IT systems. - - One example might be that as a company onboards new employees and separates from existing employees, they are added and removed from the company's electronic employee directory. :term:`SCIM` could be used to automatically add/delete (or, provision/de-provision) accounts for those users in external systems such as G Suite, Office 365, or Salesforce.com. Then, a new user account would exist in the external systems for each new employee, and the user accounts for former employees might no longer exist in those systems. - - See: `System for Cross-domain Identity Management at Wikipedia `_ - - In the context of Wire, SCIM is the interface offered by the Wire service (in particular the spar service) that allows for single or mass automated addition/removal of user accounts. - - SSO - - Single sign-on (:term:`SSO`) is an authentication scheme that allows a user to log in with a single ID and password to any of several organizationally related, yet independent, software systems. - - True single sign-on allows the user to log in once and access different, independent services without re-entering authentication factors. - - See: `Single-Sign-On at Wikipedia `_ - - SAML - - Security Assertion Markup Language (:term:`SAML`, pronounced SAM-el, /'sæməl/) is an open standard for exchanging authentication and authorization data between parties, in particular, between an identity provider and a service provider. :term:`SAML` is an XML-based markup language for security assertions (statements that service providers use to make access-control decisions). :term:`SAML` is also: - - * A set of XML-based protocol messages - * A set of protocol message bindings - * A set of profiles (utilizing all of the above) - - An important use case that :term:`SAML` addresses is web-browser `single sign-on (SSO) `_ . Single sign-on is relatively easy to accomplish within a security domain (using cookies, for example) but extending :term:`SSO` across security domains is more difficult and resulted in the proliferation of non-interoperable proprietary technologies. The `SAML Web Browser SSO `_ profile was specified and standardized to promote interoperability. - - See: `SAML at Wikipedia `_ - - In the context of Wire, SAML is the standard/protocol used by the Wire services (in particular the spar service) to provide the Single Sign On feature. - - IdP - - In the context of Wire, an identity provider (abbreviated :term:`IdP`) is a service that provides SAML single sign-on (:term:`SSO`) credentials that give users access to Wire. - - Curl - - :term:`Curl` (pronounced ":term:`Curl`") is a command line tool used to download files over the HTTP (web) protocol. For example, `curl http://wire.com` will download the ``wire.com`` web page. - - In this manual, it is used to contact API (Application Programming Interface) endpoints manually, where those endpoints would normally be accessed by code or other software. - - This can be used either for illustrative purposes (to "show" how the endpoints can be used) or to allow the manual execution of some simple tasks. - - For example (not a real endpoint) `curl http://api.wire.com/delete_user/thomas` would (schematically) execute the :term:`Curl` command, which would contact the wire.com API and delete the user named "thomas". - - Running this command in a terminal would cause the :term:`Curl` command to access this URL, and the API at that URL would execute the requested action. - - See: `curl at Wikipedia `__ - - - Spar - - The Wire backend software stack is composed of different services, `running as pods <../overview.html#focus-on-pods>`__ in a kubernetes cluster. - - One of those pods is the "spar" service. That service/pod is dedicated to the providing :term:`SSO` (using :term:`SAML`) and :term:`SCIM` services. This page is the manual for this service. - - In the context of :term:`SCIM`, Wire's spar service is the `Service Provider `__ that Identity Management Software - (for example Azure, Okta, Ping Identity, SailPoint, Technology Nexus, etc.) uses for user account provisioning and deprovisioning. - -User login for the first time with SSO -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -:term:`SSO` allows users to register and log into Wire with their company credentials that they use on other software in their workplace. -No need to remember another password. - -When a team is set up on Wire, the administrators can provide users a login code or link that they can use to go straight to their company's login page. - -Here is what this looks from a user's perspective: - -1. Download Wire. -2. Select and copy the code that your company gave you / the administrator generated -3. Open Wire. Wire may detect the code on your clipboard and open a pop-up window with a text field. - Wire will automatically put the code into the text field. - If so, click Log in and go to step 8. -4. If no pop-up: click Login on the first screen. -5. Click Enterprise Login. -6. A pop-up will appear. In the text field, paste or type the code your company gave you. -7. Click Log in. -8. Wire will load your company's login page: log in with your company credentials. - - -SAML/SSO -~~~~~~~~ - -Introduction -^^^^^^^^^^^^ - -SSO (Single Sign-On) is technology allowing users to sign into multiple services with a single identity provider/credential. - -SSO is about `authentication`, not `provisioning` (create, update, remove user accounts). To learn more about the latter, continue `below `_. - -For example, if a company already has SSO setup for some of their services, and they start using Wire, they can use Wire's SSO support to add Wire to the set of services their users will be able to sign into with their existing SSO credentials. - -Here is a blog post we like about how SAML works: https://duo.com/blog/the-beer-drinkers-guide-to-saml - -And here is a diagram that explains it in slightly more technical terms: - -.. image:: Wire_SAML_Flow.png - -Here is a critique of XML/DSig security (which SAML relies on): https://www.cs.auckland.ac.nz/~pgut001/pubs/xmlsec.txt - -Terminology and concepts -^^^^^^^^^^^^^^^^^^^^^^^^ - -* End User / Browser: The end user is generally a human, an Application (Wire Client) or a browser (agent) who accesses the Service Provider to get access to a service or a protected resource. - The browser carrries out all the redirections from the SP to the IdP and vice versa. -* Service Provider (SP): The entity (here Wire software) that provides its protected resource when an end user tries to access this resource. To accomplish the SAML based SSO authentication, the Service Provider - must have the Identity Provider's metadata. -* Identity Provider (IdP): Defines the entity that provides the user identities, including the ability to authenticate a user to get access to a protected resource / application from a Service Provider. To accomplish - the SAML based SSO authentication, the IdP must have the Service Provider's metadata. -* SAML Request: This is the authentication request generated by the Service Provider to request an authentication from the Identity Provider for verifying the user's identity. -* SAML Response: The SAML Response contains the cryptographically signed assertion of the authenticated user and is generated by the Identity Provider. - -(Definitons adapted from `collab.net `_) - -.. _Setting up SSO externally: - -Setting up SSO externally -^^^^^^^^^^^^^^^^^^^^^^^^^ - -To set up :term:`SSO` for a given Wire installation, the Team owner/administrator must enable it. - -The first step is to configure the Identity Provider: you'll need to register Wire as a service provider in your Identity Provider. - -We've put together guides for registering with different providers: - -.. toctree:: - :maxdepth: 1 - - Instructions for Okta <../../how-to/single-sign-on/okta/main.rst> - Instructions for Centrify <../../how-to/single-sign-on/centrify/main.rst> - Instructions for Azure <../../how-to/single-sign-on/azure/main.rst> - Some screenshots for ADFS <../../how-to/single-sign-on/adfs/main.rst> - Generic instructions (try this if none of the above are applicable) <../../how-to/single-sign-on/generic-setup.rst> - Trouble shooting & FAQ <../../how-to/single-sign-on/trouble-shooting.rst> - -As you do this, make sure you take note of your :term:`IdP` metadata, which you will need for the next step. - -Once you are finished with registering Wire to your :term:`IdP`, move on to the next step, setting up :term:`SSO` internally. - -Setting up SSO internally -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Now that you've registered Wire with your identity provider (:term:`IdP`), you can enable :term:`SSO` for your team on Wire. - -On Desktop: - -* Click Settings and click "Manage Team"; or go directly to teams.wire.com, or if you have an on-premise install, go to teams..com -* Login with your account credentials. -* Click "Customization". Here you will see the section for :term:`SSO`. -* Click the blue down arrow. -* Click "Add :term:`SAML` Connection". -* Provide the :term:`IdP` metadata. To find out more about retrieving this for your provider, see the guides in the "Setting up :term:`SSO` externally" step just above. -* Click "Save". -* Wire will now validate the document to set up the :term:`SAML` connection. -* If the data is valid, you will return to the Settings page. -* The page shows the information you need to log in with :term:`SSO`. Copy the login code or URL and send it to your team members or partners. For more information see: Logging in with :term:`SSO`. - -What to expect after :term:`SSO` is enabled: - -Anyone with a login through your :term:`SAML` identity provider (:term:`IdP`) and with access to the Wire app will be able to register and log in to your team using the :term:`SSO` Login URL and/or Code. - -Take care to share the code only with members of your team. - -If you haven't set up :term:`SCIM` (`we recommend you do <#introduction>`_), your team members can create accounts on Wire using :term:`SSO` simply by logging in, and will appear on the People tab of the team management page. - -If team members already have Wire accounts, use :term:`SCIM` to associate them with the :term:`SAML` credentials. If you make a mistake here, you may end up with several accounts for the same person. - -.. _User provisioning: - -User provisioning (SCIM/LDAP) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -SCIM/LDAP is about `provisioning` (create, update, remove user accounts), not `authentication`. To learn more about the latter, continue `above `_. - -Wire supports the `SCIM `__ (`RFC 7643 `__) protocol to create, update and delete users. - -If your user data is stored in an LDAP data source like Active Directory or OpenLDAP, you can use our docker-base `ldap-scim-bridge `__ to connect it to wire. - -Note that connecting a SCIM client to Wire also disables the functionality to create new users in the SSO login process. This functionality is disabled when a token is created (see below) and re-enabled when all tokens have been deleted. - -To set up the connection of your SCIM client (e.g. Azure Active Directory) you need to provide - -1. The URL under which Wire's SCIM API is hosted: ``https://prod-nginz-https.wire.com/scim/v2``. - If you are hosting your own instance of Wire then the URL is ``https:///scim/v2``, where ```` is where you are serving Wire's public endpoints. Some SCIM clients append ``/v2`` to the URL your provide. If this happens (check the URL mentioned in error messages of your SCIM client) then please provide the URL without the ``/v2`` suffix, i.e. ``https://prod-nginz-https.wire.com/scim`` or ``https:///scim``. - -2. A secret token which authorizes the use of the SCIM API. Use the `wire_scim_token.py `__ - script to generate a token. To run the script you need access to an user account with "admin" privileges that can login via email and password. Note that the token is independent from the admin account that created it, i.e. the token remains valid if the admin account gets deleted or changed. - -You need to configure your SCIM client to use the following mandatory SCIM attributes: - -1. Set the ``userName`` attribute to the desired user handle (the handle is shown - with an @ prefix in apps). It must be unique accross the entire Wire Cloud - (or unique on your own instance), and consist of the characters ``a-z0-9_.-`` - (no capital letters). - -2. Set the ``displayName`` attribute to the user's desired display name, e.g. "Jane Doe". - It must consist of 1-128 unicode characters. It does not need to be unique. - -3. The ``externalId`` attribute: - - a. If you are using Wire's SAML SSO feature then set ``externalId`` attribute to the same identifier used for ``NameID`` in your SAML configuration. - - b. If you are using email/password authentication then set the ``externalId`` - attribute to the user's email address. The user will receive an invitation email during provisioning. Also note that the account will be set to ``"active": false`` until the user has accepted the invitation and activated the account. - -You can optionally make use of Wire's ``urn:wire:scim:schemas:profile:1.0`` extension field to store arbitrary user profile data that is shown in the users profile, e.g. department, role. See `docs `__ for details. - -SCIM management in Wire (in Team Management) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -SCIM security and authentication -'''''''''''''''''''''''''''''''' - -Wire uses a very basic variant of oauth, where a *bearer token* is presented to the server in header with all :term:`SCIM` requests. - -You can create such bearer tokens in team management and copy them from there into your the dashboard of your SCIM data source. - -Generating a SCIM token -''''''''''''''''''''''' - -In order to be able to send SCIM requests to Wire, we first need to generate a SCIM token. This section explains how to do this. - -Once the token is generated, it should be noted/remembered, and it will be used in all subsequent SCIM uses/requests to authenticate the request as valid/authenticated. - -These are the steps to generate a new :term:`SCIM` token, which you will need to provide to your identity provider (:term:`IdP`), along with the target API URL, to enable :term:`SCIM` provisionning. - -* Step 1: Go to https://teams.wire.com/settings (Here replace "wire.com" with your own domain if you have an on-premise installation of Wire). - -.. image:: token-step-01.png - :align: center - -* Step 2: In the left menu, go to "Customization". - -.. image:: token-step-02.png - :align: center - -* Step 3: Go to "Automated User Management (:term:`SCIM`)" and click the "down" to expand - -.. image:: token-step-03.png - :align: center - -* Step 4: Click "Generate token", if your password is requested, enter it. - -.. image:: token-step-04.png - :align: center - -* Step 5: Once the token is generated, copy it into your clipboard and store it somewhere safe (eg., in the dashboard of your SCIM data source). - -.. image:: token-step-05.png - :align: center - -* Step 6: You're done! You can now view token information, delete the token, or create more tokens should you need them. - -.. image:: token-step-06.png - :align: center - -Tokens are now listed in this :term:`SCIM`-related area of the screen, you can generate up to 8 such tokens. - -Using SCIM via Curl -^^^^^^^^^^^^^^^^^^^ - -You can use the term:`Curl` command line HTTP tool to access tho wire backend (in particular the ``spar`` service) through the :term:`SCIM` API. - -This can be helpful to write your own tooling to interface with wire. - -Creating a SCIM token -''''''''''''''''''''' - -Before we can send commands to the :term:`SCIM` API/Spar service, we need to be authenticated. This is done through the creation of a :term:`SCIM` token. - -First, we need a little shell environment. Run the following in your terminal/shell: - -.. code-block:: bash - :linenos: - - export WIRE_BACKEND=https://prod-nginz-https.wire.com - export WIRE_ADMIN=... - export WIRE_PASSWD=... - -Wire's SCIM API currently supports a variant of HTTP basic auth. - -In order to create a token in your team, you need to authenticate using your team admin credentials. - -The way this works behind the scenes in your browser or cell phone, and in plain sight if you want to use curl, is you need to get a Wire token. - -First install the ``jq`` command (https://stedolan.github.io/jq/): - -.. code-block:: bash - - sudo apt install jq - -.. note:: - - If you don't want to install ``jq``, you can just call the ``curl`` command and copy the access token into the shell variable manually. - -Then run: - -.. code-block:: bash - :linenos: - - export BEARER=$(curl -X POST \ - --header 'Content-Type: application/json' \ - --header 'Accept: application/json' \ - -d '{"email":"'"$WIRE_ADMIN"'","password":"'"$WIRE_PASSWD"'"}' \ - $WIRE_BACKEND/login'?persist=false' | jq -r .access_token) - -This token will be good for 15 minutes; after that, just repeat the command above to get a new token. - -.. note:: - SCIM requests are authenticated with a SCIM token, see below. SCIM tokens and Wire tokens are different things. - - A Wire token is necessary to get a SCIM token. SCIM tokens do not expire, but need to be deleted explicitly. - -You can test that you are logged in with the following command: - -.. code-block:: bash - - curl -X GET --header "Authorization: Bearer $BEARER" $WIRE_BACKEND/self - -Now you are ready to create a SCIM token: - -.. code-block:: bash - :linenos: - - export SCIM_TOKEN_FULL=$(curl -X POST \ - --header "Authorization: Bearer $BEARER" \ - --header 'Content-Type: application/json;charset=utf-8' \ - -d '{ "description": "test '"`date`"'", "password": "'"$WIRE_PASSWD"'" }' \ - $WIRE_BACKEND/scim/auth-tokens) - export SCIM_TOKEN=$(echo $SCIM_TOKEN_FULL | jq -r .token) - export SCIM_TOKEN_ID=$(echo $SCIM_TOKEN_FULL | jq -r .info.id) - -The SCIM token is now contained in the ``SCIM_TOKEN`` environment variable. - -You can look it up again with: - -.. code-block:: bash - :linenos: - - curl -X GET --header "Authorization: Bearer $BEARER" \ - $WIRE_BACKEND/scim/auth-tokens - -And you can delete it with: - -.. code-block:: bash - :linenos: - - curl -X DELETE --header "Authorization: Bearer $BEARER" \ - $WIRE_BACKEND/scim/auth-tokens?id=$SCIM_TOKEN_ID - -Using a SCIM token to Create Read Update and Delete (CRUD) users -'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - -Now that you have your SCIM token, you can use it to talk to the SCIM API to manipulate (create, read, update, delete) users, either individually or in bulk. - -**JSON encoding of SCIM Users** - -In order to manipulate users using commands, you need to specify user data. - -A minimal definition of a user is written in JSON format and looks like this: - -.. code-block:: json - :linenos: - - { - "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" : "nick@example.com", - "userName" : "nick", - "displayName" : "The Nick" - } - -You can store it in a variable using this sort of command: - -.. code-block:: bash - :linenos: - - export SCIM_USER='{ - "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" : "nick@example.com", - "userName" : "nick", - "displayName" : "The Nick" - }' - -The ``externalId`` is used to construct a SAML identity. Two cases are -currently supported: - -1. ``externalId`` contains a valid email address. - The SAML ``NameID`` has the form ``me@example.com``. -2. ``externalId`` contains anything that is *not* an email address. - The SAML ``NameID`` has the form ``...``. - -.. note:: - - It is important to configure your SAML provider to use ``nameid-format:emailAddress`` or ``nameid-format:unspecified``. Other nameid formats are not supported at this moment. - - See `FAQ `_ - -We also support custom fields that are used in rich profiles in this form (see: https://github.com/wireapp/wire-server/blob/develop/docs/reference/user/rich-info.md): - -.. code-block:: bash - :linenos: - - export SCIM_USER='{ - "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:wire:scim:schemas:profile:1.0"], - "externalId" : "rnick@example.com", - "userName" : "rnick", - "displayName" : "The Rich Nick", - "urn:wire:scim:schemas:profile:1.0": { - "richInfo": [ - { - "type": "Department", - "value": "Sales & Marketing" - }, - { - "type": "Favorite color", - "value": "Blue" - } - ] - } - }' - -**How to create a user** - -You can create a user using the following command: - -.. code-block:: bash - :linenos: - - export STORED_USER=$(curl -X POST \ - --header "Authorization: Bearer $SCIM_TOKEN" \ - --header 'Content-Type: application/json;charset=utf-8' \ - -d "$SCIM_USER" \ - $WIRE_BACKEND/scim/v2/Users) - export STORED_USER_ID=$(echo $STORED_USER | jq -r .id) - -Note that ``$SCIM_USER`` is in the JSON format and is declared before running this commend as described in the section above. - -**Get a specific user** - -.. code-block:: bash - :linenos: - - curl -X GET \ - --header "Authorization: Bearer $SCIM_TOKEN" \ - --header 'Content-Type: application/json;charset=utf-8' \ - $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID - -**Search a specific user** - -SCIM user search is quite flexible. Wire currently only supports lookup by wire handle or email address. - -Email address (and/or SAML NameID, if /a): - -.. code-block:: bash - :linenos: - - curl -X GET \ - --header "Authorization: Bearer $SCIM_TOKEN" \ - --header 'Content-Type: application/json;charset=utf-8' \ - $WIRE_BACKEND/scim/v2/Users/'?filter=externalId%20eq%20%22me%40example.com%22' - -Wire handle: same request, just replace the query part with - -.. code-block:: bash - - '?filter=userName%20eq%20%22me%22' - -**Update a specific user** - -For each put request, you need to provide the full json object. All omitted fields will be set to ``null``. (If you do not have an up-to-date user present, just ``GET`` one right before the ``PUT``.) - -.. code-block:: bash - :linenos: - - export SCIM_USER='{ - "schemas" : ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" : "rnick@example.com", - "userName" : "newnick", - "displayName" : "The New Nick" - }' - -.. code-block:: bash - :linenos: - - curl -X PUT \ - --header "Authorization: Bearer $SCIM_TOKEN" \ - --header 'Content-Type: application/json;charset=utf-8' \ - -d "$SCIM_USER" \ - $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID - -**Deactivate user** - -It is possible to temporarily deactivate an user (and reactivate him later) by setting his ``active`` property to ``true/false`` without affecting his device history. (`active=false` changes the wire user status to `suspended`.) - -**Delete user** - -.. code-block:: bash - :linenos: - - curl -X DELETE \ - --header "Authorization: Bearer $SCIM_TOKEN" \ - $WIRE_BACKEND/scim/v2/Users/$STORED_USER_ID From 4bfc91a7d4e50df3bf6386e092a42ae0c948ab21 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Thu, 12 Jan 2023 17:41:05 +0100 Subject: [PATCH 23/33] Update Federation docs (#2982) --- .../how-to/install/configure-federation.md | 107 +++--- docs/src/understand/federation/api.md | 342 +++++++++--------- .../src/understand/federation/architecture.md | 313 +++------------- .../federation/backend-communication.md | 167 +++++++++ docs/src/understand/federation/glossary.md | 108 ------ .../federation/img/federation-apis-flow.png | Bin 0 -> 64907 bytes .../federation/img/federation-apis-flow.txt | 32 ++ .../federation/img/federation-flow.png | Bin 144554 -> 142892 bytes .../federation/img/federation-flow.txt | 55 ++- docs/src/understand/federation/index.md | 30 +- .../src/understand/federation/introduction.md | 45 --- docs/src/understand/federation/replace.sh | 11 - docs/src/understand/federation/roadmap.md | 112 ------ 13 files changed, 533 insertions(+), 789 deletions(-) create mode 100644 docs/src/understand/federation/backend-communication.md delete mode 100644 docs/src/understand/federation/glossary.md create mode 100644 docs/src/understand/federation/img/federation-apis-flow.png create mode 100644 docs/src/understand/federation/img/federation-apis-flow.txt delete mode 100644 docs/src/understand/federation/introduction.md delete mode 100644 docs/src/understand/federation/replace.sh delete mode 100644 docs/src/understand/federation/roadmap.md diff --git a/docs/src/how-to/install/configure-federation.md b/docs/src/how-to/install/configure-federation.md index 38d53af30f..c7e46849bc 100644 --- a/docs/src/how-to/install/configure-federation.md +++ b/docs/src/how-to/install/configure-federation.md @@ -1,20 +1,12 @@ (configure-federation)= # Configure Wire-Server for Federation - -## Background +See also {ref}`federation-understand`, which explains the architecture and concepts. -Please first understand the current scope and aim of wire-server -federation by reading -{ref}`Understanding federation `. - -```{warning} -As of October 2021, federation implementation is still work in progress. -Many features are not implemented yet, and it should be considered -\"alpha\": stability, and upgrade compatibility are not guaranteed. +```{note} +The Federation development is work in progress. ``` - ## Summary of necessary steps to configure federation The steps needed to configure federation are as follows and they will be @@ -22,32 +14,32 @@ detailed in the sections below: - Choose a backend domain name -- DNS setup for federation (including an `SRV` record) +- DNS setup for federation (including a `SRV` record) - Generate and configure TLS certificates: - > - server certificates - > - client certificates - > - a selection of CA certificates you trust when interacting with - > other backends + - server certificates + - client certificates + - a selection of CA certificates you trust when interacting with + other backends - Configure helm charts : federator and ingress and webapp subcharts - Test that your configurations work as expected. (choose-backend-domain)= -## Choose a {ref}`Backend Domain Name` - -As of the release \[helm chart 0.129.0, Wire docker version 2.94.0\] -from 2020-12-15, a Backend Domain (set as `federationDomain` in -configuration) is a mandatory configuration setting. Regardless of -whether you want to enable federation for a backend or not, you must -decide what its domain is going to be. This helps in keeping things -simpler across all components of Wire and also enables to turn on +## Choose a Backend Domain + +As of the release \[helm chart 0.129.0, Wire docker version 2.94.0\] from +2020-12-15, the `federationDomain` is a mandatory configuration setting, which +defines the {ref}`backend domain ` of your +installation. Regardless of whether you want to enable federation for a backend +or not, you must decide what its domain is going to be. This helps in keeping +things simpler across all components of Wire and also enables to turn on federation in the future if required. It is highly recommended that this domain is configured as something - * [ ] that is controlled by the administrator/operator(s). The actual servers +that is controlled by the administrator/operator(s). The actual servers do not need to be available on this domain, but you MUST be able to set an SRV record for `_wire-server-federator._tcp.` that informs other wire-server backends where to find your actual servers. @@ -57,41 +49,40 @@ breaking experience for all the users which are already using the backend. (consequences-backend-domain)= -## Consequences of the choice of Backend Domain +## Consequences of the choice of a backend domain -- You need control over a specific subdomain of this Backend Domain +- You need control over a specific subdomain of this backend domain (to set an SRV DNS record as explained in the next section). Without this control, you cannot federate with anyone. -- This Backend Domain becomes part of the underlying identify of all +- This backend domain becomes part of the underlying identity of all users on your servers. - > - Example: Let\'s say you choose `example.com` as your Backend - > Domain. Your user known to you as Alice, and known on your - > server with ID `ac41a202-2555-11ec-9341-00163e5e6c00` will - > become known for other servers you federate with as - > - > ``` json - > { - > "user": { - > "id": "ac41a202-2555-11ec-9341-00163e5e6c00", - > "domain": "example.com" - > } - > } - > ``` - -- As of October 2021, this domain is used in the User Interface - alongside user information. (This may or may not change in the - future) - - > - Example: Using the same example as above, for backends you - > federate with, Alice would be displayed with the - > human-readable username `@alice@example.com` for users on - > other backends. + Example: Let\'s say you choose `example.com` as your backend + domain. Your user known to you as Alice, and known on your + server with ID `ac41a202-2555-11ec-9341-00163e5e6c00` will + become known for other servers you federate with as + + ``` json + { + "user": { + "id": "ac41a202-2555-11ec-9341-00163e5e6c00", + "domain": "example.com" + } + } + ``` + +- This domain is shown in the User Interface + alongside user information. + + Example: Using the same example as above, for backends you + federate with, Alice would be displayed with the + human-readable username `@alice@example.com` for users on + other backends. ```{warning} -As of October 2021, *changing* this Backend Domain after existing user -activity with a recent version (versions later than \~May/June 2021) +*Changing* the backend domain after existing user +activity with a client version (versions later than May/June 2021) will lead to undefined behaviour (untested, not accounted for during development) on some or all client platforms (Web, Android, iOS) for those users: It is possible your clients could crash, or lose part of @@ -127,7 +118,7 @@ The fields of the SRV record need to be populated as follows - `weight`: \>0 for your server to be reachable. A good default value could be 10 - `port`: `443` -- `target`: \ +- `target`: the infra domain To give an example, assuming @@ -137,7 +128,7 @@ To give an example, assuming `.wire.example.org` then your federation -{ref}`Infra Domain ` +{ref}`Infrastructure Domain ` would be `federator.wire.example.org`. The SRV record would look as follows: @@ -159,6 +150,7 @@ alongside your other DNS records that point to the ingress component, also needs to point to the IP of your ingress, i.e. the IP you want to provide services on. +(federation-certificate-setup)= ## Generate and configure TLS server and client certificates Are your servers on the public internet? Then you have the option of @@ -169,7 +161,7 @@ public internet or you would like to use your own CA, go to subsection ```{admonition} Note -As of Jan 2022, we\'re using the +As of January 2023, we\'re using the [hs-tls](https://hackage.haskell.org/package/tls) library for outgoing TLS connections to other backends, which only supports P256 for ECDSA keys. Therefore, we have specified a [key size of 256 @@ -252,7 +244,7 @@ You can use one single certificate and key for both server and client certificate use. ```{note} -Currently (October 2021), due to a limitation of the TLS library in use +Due to a limitation of the TLS library in use for federation ([hs-tls](https://github.com/vincenthz/hs-tls)), only some ciphers are supported. Moving to an openssl-based library is planned, which will provide support for a wider range of ciphers. @@ -449,6 +441,7 @@ tls: verify_depth: 3 # default: 1 ``` +(configure-federation-allow-list)= ### Configure the allow list By default, federation is turned off (allow list set to the empty list): @@ -525,7 +518,7 @@ DOMAIN to your {ref}`federation infra domain `. They should include your domain as part of the SAN (Subject Alternative Names) and not have expired. -### Manually test that federation \"works\" +### Manually test that federation works Prerequisites: diff --git a/docs/src/understand/federation/api.md b/docs/src/understand/federation/api.md index a11e38397d..2a8325606c 100644 --- a/docs/src/understand/federation/api.md +++ b/docs/src/understand/federation/api.md @@ -1,48 +1,77 @@ (federation-api)= -# API +# Federation API -The Federation API consists of two *layers*: +(qualified-identifiers-and-names)= +## Qualified Identifiers and Names -1. Between two backends (i.e. between a *Federator* and a - *Federation Ingress*) -2. Between backend-internal components +The federated architecture is reflected in the structure of the various +identifiers and names used in the API. Identifiers, such as user ids, are unique +within the context of a backend. They are made unique within the context of all +federating backend by combining them with the {ref}`backend domain +`. -(qualified-identifiers-and-names)= +For example a user with user id `d389b370-5f7d-4efd-9f9a-8d525540ad93` on +backend `b.example.com` has the *qualified user id* +`d389b370-5f7d-4efd-9f9a-8d525540ad93@b.example.com`. In API request bodies +qualified identities are encoded as objects, e.g. -## Qualified Identifiers and Names +``` +{ + "user": { + "id": "d389b370-5f7d-4efd-9f9a-8d525540ad93", + "domain": "b.example.com" + } + ... +} -The federated (and consequently distributed) architecture is reflected -in the structure of the various identifiers and names used in the API. -Before federation, identifiers were only unique in the context of a -single backend; for federation, they are made globally unique by -combining them with the federation domain of their backend. We call -these combined identifiers *qualified* identifiers. While other parts of -some identifiers or names may change, the domain name (i.e. the -qualifying part) is static. - -In particular, we use the following identifiers throughout the API: - -- {ref}`glossary_qualified-user-id`: *user_uuid@backend-domain.com* -- {ref}`glossary_qualified-user-name`: *user_name@backend-domain.com* -- {ref}`glossary_qualified-client-id` attached to a QUID: *client_uuid.user_uuid@backend-domain.com* -- {ref}`glossary_qualified-conversation-id` / {ref}`glossary_qualified-group-id`: *backend-domain.com/groups/group_uuid* -- {ref}`glossary_qualified-team-id`: *backend-domain.com/teams/team_uuid* - -While the canonical representation for purposes of visualization is as -displayed above, the API often decomposes the qualified identifiers into -an (unqualified) id and a domain name. In the code and API -documentation, we sometimes call a username a \"handle\" and a qualified -username a \"qualified handle\". - -Besides the above names and identifiers, there are also user -{ref}`glossary_display-name` (sometimes also -referred to as \"profile names\"), which are not unique on the user\'s -backend, can be changed by the user at any time and are not qualified. +``` +In API path segments qualified identities are encoded with the domain first, e.g. +``` +POST /connections/b.example.com/d389b370-5f7d-4efd-9f9a-8d525540ad93 +``` +to send a connection request to a user. + +Any identifier on a backend can be qualified: + +- conversation ids +- team ids +- client ids +- user ids +- user handles, e.g. local handle `@alice` is displayed as `@alice@b.example.com` in federating users' devices + +User profile names (e.g. "Alice") which are not unique on the user\'s backend, +can be changed by the user at any time and are not qualified. (api-between-federators)= -## API between Federators +## Federated requests + +Every federated API request is made by a service component (e.g. brig, galley, +cargohold) in one backend and responded to by a service component in the other +backend. The *Federators* of the backends are relaying the request between the +components across backends . The components talk to each other via the +*Federator* in the originating domain and *Federator Ingress* in the receiving +domain (for details see {ref}`backend-to-backend-communication`). + + +```{figure} ./img/federation-apis-flow.png +--- +width: 100% +--- +Federators relaying a request between components. See {ref}`federation-back2back-example` to see the discovery, authentication and authorization steps that are omitted from this figure. +``` + +### API From Components to Federator + + +When making the call to the *Federator*, the components use HTTP2. They call the +Federator's `Outward` service, which accepts `POST` requests with path +`/rpc/:domain/:component/:rpc`. Such a request will be forwarded to the remote +Federator with the given {ref}`backend domain`, and converted +to the appropriate request of its `Inward` service. + +### API between Federators The layer between *Federator* acts as an envelope for communication between other components of wire server. The *Inward* service of @@ -68,19 +97,10 @@ See {ref}`api-from-federator-to-components` for more details on RPCs and their p (api-from-components-to-federator)= -## API From Components to Federator - -Between two federated backends, the components talk to each other via the -*Federator* in the originating domain and *Ingress* in the receiving domain. -When making the call to the *Federator*, the components use HTTP2. They call the -`Outward` service, which accepts `POST` requests with path -`/rpc/:domain/:component/:rpc`. Such a request will be forwarded to a remote -federator with the given {ref}`Backend-domains`, and converted to the -appropriate request for its `Inward` service. (api-from-federator-to-components)= -## API From Federator to Components +### API From Federator to Components The components expose a REST API over HTTP to be consumed by the *Federator*. All the paths start with `/federation`. When a *Federator* @@ -107,13 +127,10 @@ attacks such as attempting to access `/federation/../users/by-handle`. ## List of Federation APIs exposed by Components Each component of the backend provides an API towards the *Federator* -for access by other backends. For example on how these APIs are used, -see the section on -`end-to-end flows`{.interpreted-text role="ref"}. - +for access by other backends. ```{note} -This reflects status of API endpoints as of 2022-01-28. For latest APIs please +This reflects status of API endpoints as of 2023-01-10. For latest APIs please refer to the corresponding source code linked in the individual section. ``` @@ -139,10 +156,15 @@ the backend. w.r.t. that term. - `get-user-clients`: Given a list of user ids, return the lists of clients of each of the users. +- `get-user-clients`: Given a list of user ids, return a list of all their clients with public information +- `send-connection-action`: Make and also respond to user connection requests +- `on-user-deleted-connections`: Notify users that are connected to remote user about that user's deletion +- `get-mls-clients`: Request all [MLS](../../how-to/install/mls)-capable clients for a given user +- `claim-key-packages`: Claim a previously-uploaded KeyPackage of a remote user. User for adding users to MLS conversations. See [the brig source code](https://github.com/wireapp/wire-server/blob/master/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs) -for the current list of federated endpoints of the *Brig*, as well as +for the current list of federated endpoints of *Brig*, as well as their precise inputs and outputs. (galley)= @@ -153,46 +175,63 @@ Each backend keeps a record of the conversations that each of its members is a part of. The purpose of the Galley API is to allow backends to synchronize the state of the conversations of their members. -- `on-conversation-created`: Given a name and a list of conversation - members, create a conversation locally. This is used to inform - another backend of a new conversation that involves their local - user(s). -- `get-conversations`: Given a qualified user id and a list of +- `get-conversations`: Given a qualified user id and a list of conversation ids, return the details of the conversations. This allows a remote backend to query conversation metadata of their local user from this backend. To avoid metadata leaks, the backend will check that the domain of the given user corresponds to the domain of the backend sending the request. -- `on-conversation-updated`: Given a qualified user id and a qualified +- `get-sub-conversation`: Get a MLS subconversation +- `leave-conversation`: Given a remote user and a conversation id, + remove the the remote user from the (local) conversation. +- `mls-welcome`: Send MLS welcome message to a new user owned by the called backend +- `on-client-removed`: Inform called backend that a client of a user has been deleted +- `on-conversation-created`: Given a name and a list of conversation + members, create a conversation locally. This is used to inform + another backend of a new conversation that involves their local + user(s). +- `on-conversation-updated`: Given a qualified user id and a qualified conversation id, update the conversation details locally with the other data provided. This is used to alert remote backend of updates in the conversation metadata of conversations in which at least one of their local users is involved. -- `leave-conversation`: Given a remote user and a conversation id, - remove the the remote user from the (local) conversation. -- `on-message-sent`: Given a remote message and a conversation id, +- `on-message-sent`: Given a remote message and a conversation id, propagate a message to local users. This is used whenever there is a remote user in a conversation (see end-to-end flows). -- `send-message`: Given a sender and a raw message request, send a +- `on-mls-message-sent`: Receive a MLS message that originates in the calling backend +- `on-new-remote-conversation`: Inform the called backend about a conversation that exists on the calling backend. This request is made before the first time the backend might learn about this conversation, e.g. when its first user is added to the conversation. +- `on-typing-indicator-updated`: Used by the calling backend (that owns a conversation) to inform the called backend about a change of the typing indicator status of remote user +- `on-user-deleted-conversations`: When a user on calling backend this request is made for all conversations on the called backend was part of +- `query-group-info`: Query the MLS public group state +- `send-message`: Given a sender and a raw message request, send a message to a conversation owned by another backend. This is used when the user sending a message is not on the same backend as the conversation the message is sent in. +- `send-mls-commit-bundle`: Send a MLS commit bundle to backend that owns the conversation +- `send-mls-message`: Send MLS message to backend that owns the conversation +- `update-conversation`: Calling backend requests a conversation action on the called backend which owns the conversation See [the galley source code](https://github.com/wireapp/wire-server/blob/master/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs) -for the current list of federated endpoints of the *Galley*, as well as +for the current list of federated endpoints of *Galley*, as well as their precise inputs and outputs. (end-to-end-flows)= -## End-to-End Flows +### Cargohold +- `get-asset`: Check if asset owned by called backend is available to calling backend +- `stream-asset`: Stream asset owned by the called backend -In the following end-to-end flows, we focus on the interaction between -the Brigs and Galleys of federated backends. While the interactions are -facilitated by the *Federator* and *Federation Ingress* components of -the backends involved, which handle the necessary discovery, -authentication and authorization steps, we won\'t mention these steps -explicitly each time to keep the flows simple. +See [the cargohold source +code](https://github.com/wireapp/wire-server/blob/master/libs/wire-api-federation/src/Wire/API/Federation/API/Cargohold.hs) +for the current list of federated endpoints of the *Cargohold*, as well as +their precise inputs and outputs. + +## Example End-to-End Flows + +In the following the interactions between *Federator* and *Federation Ingress* +components of the backends involved are omitted for simplicity. Also the backend +domain and infra domain are assumed the same. Additionally we assume that the backend domain and the infra domain of the respective backends involved are the same and each domain identifies @@ -202,125 +241,98 @@ a distinct backend. ### User Discovery -In this flow, the user *A* at *backend-a.com* tries to search for user -*B* at *backend-b.com*. +In this flow, the user *Alice* at *a.example.com* tries to search for user +*Bob* at *b.example.com*. -1. User *A@backend-a.com* enters the qualified user name of the target - user *B@backend-b.com* into the search field of their Wire client. +1. User *Alice* enters the qualified user name of the target + user *Bob* : `@bob@b.example.com` into the search field of their Wire client. 2. The client issues a query to `/search/contacts` of the Brig - searching for *B* at *backend-b.com*. -3. The Brig in *A*\'s backend asks its local *Federator* to query the - `search-users` endpoint of B\'s backend for *B*. -4. *A*\'s *Federator* queries *B*\'s Brig via *B*\'s *Federation + searching for *Bob* at *b.example.com*. +3. The Brig in *Alice*\'s backend asks its local *Federator* to query the + `search-users` endpoint in *Bob*\'s backend. +4. *Alice*\'s *Federator* queries *Bob*\'s Brig via *Bob*\'s *Federation Ingress* and *Federator* as requested. -5. *B*\'s Brig replies with *B*\'s user name and qualified handle, the - response goes through *B*\'s *Federator* and *Federation Ingress*, - as well as *A*\'s *Federator* before it reaches *A*\'s Brig. -6. *A*\'s Brig forwards that information to *A*\'s client. +5. *Bob*\'s Brig replies with *Bob*\'s user name and qualified handle, the + response goes through *Bob*\'s *Federator* and *Federation Ingress*, + as well as *Alice*\'s *Federator* before it reaches *A*\'s Brig. +6. *Alice*\'s Brig forwards that information to *A*\'s client. (conversation-establishment)= ### Conversation Establishment -After having discovered user *B* at *backend-b.com*, user *A* at -*backend-a.com* wants to establish a conversation with *B*. +After having discovered user *Bob* at *b.example.com*, user *Alice* at +*a.example.com* wants to establish a conversation with *Bob*. 1. From the search results of a {ref}`user discovery` - process, *A* chooses to create a conversation with *B*. -2. *A*\'s client issues a `/users/backend-b.com/B/prekeys` query to - *A*\'s Brig. -3. *A*\'s Brig asks its *Federator* to query the `claim-prekey-bundle` - endpoint of *B*\'s backend using *B*\'s user id. -4. *B*\'s *Federation Ingress* forwards the query to the *Federator*, + process, *Alice* chooses to create a conversation with *Bob*. +2. *Alice*\'s client issues a `/users/b.example.com//prekeys` query to + *Alice*\'s Brig. +3. *Alice*\'s Brig asks its *Federator* to query the `claim-prekey-bundle` + endpoint of *Bob*\'s backend using *Bob*\'s user id. +4. *Bob*\'s *Federation Ingress* forwards the query to the *Federator*, who in turn forwards it to the local Brig. -5. *B*\'s Brig replies with a prekey bundle for each of *B*\'s clients, - which is forwarded to *A*\'s Brig via *B*\'s *Federator* and - *Federation Ingress*, as well as *A*\'s *Federator*. -6. *A*\'s Brig forwards that information to *A*\'s client. -7. *A*\'s client queries the `/conversations` endpoint of its Galley - using *B*\'s user id. -8. *A*\'s Galley creates the conversation locally and queries the - `on-conversation-created` endpoint of *B*\'s Galley (again via its - local *Federator*, as well as *B*\'s *Federation Ingress* and +5. *Bob*\'s Brig replies with a prekey bundle for each of *Bob*\'s clients, + which is forwarded to *Alice*\'s Brig via *Bob*\'s *Federator* and + *Federation Ingress*, as well as *Alice*\'s *Federator*. +6. *Alice*\'s Brig forwards that information to *A*\'s client. +7. *Alice*\'s client queries the `/conversations` endpoint of its Galley + using *Bob*\'s user id. +8. *Alice*\'s Galley creates the conversation locally and queries the + `on-conversation-created` endpoint of *Bob*\'s Galley (again via its + local *Federator*, as well as *Bob*\'s *Federation Ingress* and *Federator*) to inform it about the new conversation, including the conversation metadata in the request. -9. *B*\'s Galley registers the conversation locally and confirms the +9. *Bob*\'s Galley registers the conversation locally and confirms the query. -10. *B*\'s Galley notifies *B*\'s client of the creation of the +10. *Bob*\'s Galley notifies *Bob*\'s client of the creation of the conversation. (message-sending-a)= -### Message Sending (A) - -Having established a conversation with user *B* at *backend-b.com*, user -*A* at *backend-a.com* wants to send a message to user *B*. - -1. In a conversation *conv-1@backend-a.com* on *A*\'s backend with - users *A@backend-a.com* and *B@backend-b.com*, *A* sends a message - by using the `/conversations/backend-a.com/conv-1/proteus/messages` - endpoint on *A*\'s Galley. -2. *A*\'s Galley checks if *A* included all necessary user devices in - their request. For that it makes a `get-user-clients` request to - *B*\'s Galley. *A*\'s Galley checks that the returned list of - clients matches the list of clients the message was encrypted for. -3. *A*\'s Galley sends the message to all clients in the conversation - that are part of *A*\'s backend. -4. *A*\'s Galley queries the `on-message-sent` endpoint on *B*\'s - Galley via its *Federator* and *B*\'s *Federation Ingress* and - *Federator*. -5. *B*\'s Galley will propagate the message to all local clients - involved in the conversation. - -(message-sending-b)= - -### Message Sending (B) +### Message Sending -Having received a message from user *A* at *backend-a.com*, user *B* at -*backend-b.com* wants send a reply. +Having established a conversation with user *Bob* at *b.example.com*, user +*Alice* at *a.example.com* wants to send a message to user *Bob*. -1. In a conversation *conv-1@backend-a.com* on *A*\'s backend with - users *A@backend-a.com* and *B@backend-b.com*, *B* sends a message - by using the `/conversations/backend-a.com/conv-1/proteus/messages` - endpoint on *B*\'s backend. -2. *B*\'s Galley queries the `send-message` endpoint on *A*\'s backend. - *Steps 3-6 below are essentially the same as steps 2-5 in Message - Sending (A)* -3. *A*\'s Galley checks if *A* included all necessary user devices in +1. In a conversation *\@a.example.com* on *Alice*\'s backend with + users *Alice* and *Bob*, *Alice* sends a message + by using the `/conversations/a.example.com//proteus/messages` + endpoint on *Alice*\'s Galley. +2. *Alice*\'s Galley checks if *A* included all necessary user devices in their request. For that it makes a `get-user-clients` request to - *B*\'s Galley. *A*\'s Galley checks that the returned list of + *Bob*\'s Galley. *Alice*\'s Galley checks that the returned list of clients matches the list of clients the message was encrypted for. -4. *A*\'s Galley sends the message to all clients in the conversation - that are part of *A*\'s backend. -5. *A*\'s Galley queries the `on-message-sent` endpoint on *B*\'s - Galley via its *Federator* and *B*\'s *Federation Ingress* and +3. *Alice*\'s Galley sends the message to all clients in the conversation + that are part of *Alice*\'s backend. +4. *Alice*\'s Galley queries the `on-message-sent` endpoint on *Bob*\'s + Galley via its *Federator* and *Bob*\'s *Federation Ingress* and *Federator*. -6. *B*\'s Galley will propagate the message to all local clients +5. *Bob*\'s Galley will propagate the message to all local clients involved in the conversation. -(error-codes)= - -## Error Codes - -This page describes the errors that can occur during federation. - -(authentication-errors)= - -### Authentication Errors - -TODO for now, we only describe the errors here. Later, we should add -exact error codes. - -TODO we might want to merge one or more of these errors - -- *authentication error*: occurs when a backend queries another - backend and provides either no client certificate, or a client - certificate that the receiving backend cannot authenticate -- *authorization error*: occurs when a sending backend - authenticates successfully, but is not on the allow list of the - receiving backend -- *discovery error*: occurs when a sending backend authenticates - successfully, but the [SRV]{.title-ref} record published for the - claimed domain of the sending backend doesn\'t match the SAN of the - sending backend\'s client certificate +## Ownership + +Wire uses the concept of **ownership** as a guiding principle in the design of +Federation. Every resource, e.g. user, conversation, asset, is **owned** by the +backend on which it was *created*. + +A backend that owns a resource is the source of truth for it. For example, for +users this means that information about user *Alice* which is owned by backend +*A* is stored only on backend *A*. If any federating backend needs information +about the user *Alice*, e.g. the profile information, it needs to request that +information from *A*. + +In some cases backends locally store partial information of resources they don't +own. For example a backend stores a reference to any remotely-owned conversation +any of its users is participating in. However, to get the full list of all +participants of a remote conversation, the owning backend needs to be queried. + +Ownership is reflected in the naming convention of federation RPCs. Any rpc +named with prefix `on-` is always invoked by the backend that owns the resource +to inform federating backends. For example, if a user leaves a remote +conversation its backend would call the `leave-conversation` rpc on the remote +conversation. The remote backend would remove the user and inform all other +federating backends that participate in that conversation of this change by +calling their `on-conversation-updated` rpc. diff --git a/docs/src/understand/federation/architecture.md b/docs/src/understand/federation/architecture.md index 5d3c7ec762..392806ac91 100644 --- a/docs/src/understand/federation/architecture.md +++ b/docs/src/understand/federation/architecture.md @@ -1,67 +1,54 @@ -(architecture-and-network)= +(federation-architecture)= +# Federation Achitecture -# Architecture and Network +(glossary_backend)= -(federation-architecture)= +## Backends -## Architecture +In the following we call a **backend** the set of servers, databases and DNS +configurations that together form one single Wire Server entity as seen from +outside. It can also be called a Wire \"instance\" or \"server\" or \"Wire +installation\". Every resource (e.g. users, conversations, assets and teams) +exists and is *owned* by a single backend, which we can refer to as that +resource\'s backend. -To facilitate connections between federated backends, two new components -are added to each backend: -{ref}`federation_ingress` and -{ref}`federator`. The -*Federation Ingress* is, as the name suggests the ingress point for -incoming connections from other backends, which are then forwarded to -the *Federator*. The *Federator* then further processes the requests. In -addition, the *Federator* also acts as *egress* point for requests from +The communication between federated backends is facilitated by two components in +each backend: {ref}`federation_ingress` and {ref}`federator`. The +*Federation Ingress* is, as the name suggests the +ingress point for incoming connections from other backends, which are then +forwarded to the *Federator*. The *Federator* forwards requests +to internal components. It also acts as a *egress* point for requests from internal backend components to other, remote backends. -![image](img/federated-backend-architecture.png){width="100.0%"} +![image](img/federated-backend-architecture.png) (backend-domains)= -### Backend domains +(glossary_infra_domain)= +(glossary_backend_domain)= -Each backend has two domain strings: an *infrastructure domain* and a -*backend domain*. +## Backend domains -The *infrastructure domain* is the domain name under which the backend +Each backend has two domain: an {ref}`infrastructure domain ` and a +{ref}`backend domain `. + +The **infrastructure domain** (short **infra domain**) is the domain name under which the backend is actually reachable via the network. It is also the domain name that each backend uses in authenticating itself to other backends. -Similarly, there is the *backend domain*, which is used to qualify the +Similarly, there is the **backend domain**, which is used to {ref}`qualify ` the names and identifiers of users local to an individual backend in the -context of federation. For example, a user with (unqualified) user name -*jane_doe* at a backend with backend domain *company-a.com* has the -qualified user name *jane_doe@company-a.com*, which is visible to users -of other backends in the context of federation. - -See -{ref}`qualified-identifiers-and-names` -The distinction between the two domains allows the owner of a (backend) -domain (e.g. *company-a.com*) to host their Wire backend under a -different (infra) domain (e.g. *wire.infra.company-a.com*). +context of federation. -(backend-components)= - -### Backend components - -In addition to the regular components of a Wire backend, two additional -components are added to enable federation with other backends: The -*Federation Ingress* and the *Federator*. Other Wire components use -these two components to contact other backends and respond to queries -originating from remote backends. - -The following subsections briefly introduce the individual components, -their state and their functionality. The semantics of backend-to-backend -communication will be explained in more detail in the Section on -{ref}`federation-api` +The distinction between the two domains allows the owner of a backend +domain, e.g. `example.com`, to host their Wire backend under a +different infra domain, e.g. `wire.infra.example.com`. (federation_ingress)= -#### Federation Ingress +## Federation Ingress -The *Federation Ingress* is a [kubernetes +The *Federation Ingress* is a [Kubernetes ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) and uses [nginx](https://nginx.org/en/) as its underlying software. @@ -72,16 +59,14 @@ other backends. Its functions are: -- terminate TLS connections - - perform mutual {ref}`authentication` as - part of the TLS connection establishment -- forward requests to the local - {ref}`federator` instance, - along with the remote backend\'s client certificate +- to terminate TLS connections +- to perform mutual {ref}`authentication` as part of the TLS connection establishment +- to forward requests to the local {ref}`federator` instance, along with the + remote backend\'s client certificate (federator)= -#### Federator +## Federator The *Federator* performs additional authorization checks after receiving federated requests from the *Federation Ingress* and acts as egress @@ -96,14 +81,11 @@ Additionally, it requires a connection to a DNS resolver to When receiving a request from an internal component, the *Federator* will: -1. If enabled, ensure the target domain is in the - {ref}`allow-list` -2. {ref}`discover ` the other - backend, -3. establish a - {ref}`mutually authenticated channel ` to the other backend using its client certificate, -4. send the request to the other backend and -5. forward the response back to the originating component (and +1. If enabled, ensure the target domain is in the allow list, +2. Discover the other backend, +3. Establish a {ref}`mutually authenticated channel ` to the other backend using its client certificate, +4. Send the request to the other backend and +5. Forward the response back to the originating component (and eventually to the originating Wire client). The *Federator* also implements the authorization logic for incoming @@ -112,20 +94,20 @@ the internal components. The *Federator* will, for incoming requests from remote backends (forwarded via the local {ref}`Federation Ingress `): -1. {ref}`Discover ` the mapping +1. Discover the mapping between backend domain claimed by the remote backend and its infra domain, -2. verify that the discovered infra domain matches the domain in the +2. Verify that the discovered infra domain matches the domain in the remote backend\'s client certificate, -3. if enabled, ensure that the backend domain of the other backend is - in the {ref}`allow list `, -4. forward requests to other wire-server components. +3. If enabled, ensure that the backend domain of the other backend is + in the allow list. +4. Forward requests to other wire-server components. (other-wire-server)= -#### Other wire-server components +## Service components -Components such as \'brig\', \'galley\', or \'gundeck\' are responsible +Components such as Brig, Galley, Cargohold are responsible for actual business logic and interfacing with databases and non-federation related external services. See [source code documentation](https://github.com/wireapp/wire-server). In the context @@ -134,205 +116,8 @@ of federation, their functions include: - For incoming requests from other backends: {ref}`per-request authorization` - Outgoing requests to other backends are always sent via a local - {ref}`Federator` instance. + Federator instance. For more information of the functionalities provided to remote backends through their *Federator*, see the {ref}`federated API documentation`. - -(backend-to-backend-communication)= - -## Backend to backend communication - -We require communication between the *Federator* of one (sending) -backend and the ingress of another (receiving) backend to be both -mutually authenticated and authorized. More specifically, both backends -need to ensure the following: - -- **Authentication** - - Determine the identity (infra domain name) of the other backend. - -- **Discovery** - - Ensure that the other backend is authorized to represent the backend - domain claimed by the other backend. - -- **Authorization** - - Ensure that this backend is authorized to federate with the other backend. - -(authentication)= - -### Authentication - -```{warning} -As of January 2022, the implementation of mutual backend-to-backend authentication is still subject to change. The behaviour described in this section should be considered a draft specification only. -``` - -Authentication between Wire backends is achieved using the mutual -authentication feature of TLS as defined in [RFC -8556](https://tools.ietf.org/html/rfc8446). - -In particular, this means that the ingress of each backend needs to be -provisioned with one or more certificates which the ingress trusts to -authenticate certificates provided by other backends when accepting -incoming connections. - -Conversely, every *Federator* needs to be provisioned with a (client) -certificate which it uses to authenticate itself towards other backends. - -Note that the client certificate is expected to be issued with the -backend\'s infra domain as one of the subject alternative names (SAN), -which is defined in [RFC 5280](https://tools.ietf.org/html/rfc5280). - -If a receiving backend fails to authenticate the client certificate, it should -reply with an *authentication error* (see {ref}`authentication-errors`) - -(discovery)= - -### Discovery - -The discovery process allows a backend to determine the infra domain of -a given backend domain. - -This step is necessary in two scenarios: - -- A backend would like to establish a connection to another backend - that it only knows the backend domain of. This is the case, for - example, when a user of a local backend searches for a - {ref}`qualified username `, which only includes that user\'s backend\'s backend - domain. -- When receiving a message from another backend that authenticates - with a given infra domain and claims to represent a given backend - domain, a backend would like to ensure the backend domain owner - authorized the owner of the infra domain to run their Wire backend. - -To make discovery possible, any party hosting a Wire backend has to -announce the infra domain via a DNS *SRV* record as defined in [RFC -2782](https://tools.ietf.org/html/rfc2782) with -`service = wire-server-federator, proto = tcp` and with `name` pointing -to the backend\'s domain and *target* to the backend\'s infra domain. - -For example, Company A with backend domain *company-a.com* and infra -domain *wire.company-a.com* could publish - -``` bash -_wire-server-federator._tcp.company-a.com. 600 IN SRV 10 5 443 federator.wire.company-a.com. -``` - -A backend can then be discovered, given its domain, by issuing a DNS -query for the SRV record specifying the *wire-server-federator* service. - -(dns-scope)= - -#### DNS Scope - -The network scope of the SRV record (as well as that of the DNS records -for backend and infra domain), depends on the desired federation -topology in the same way as other parameters such as the availability of -the CA certificate that allows authentication of the *Federation -Ingress*\' server certificate or the *Federator*\'s client certificate. -The general rule is that the SRV entry should be \"visible\" from the -point of view of the desired federation partners. The exact scope -strongly depends on the network architecture of the backends involved. - -(srv-ttl-and-caching)= - -#### SRV TTL and Caching - -After retrieving the SRV record for a given domain, the local backend -caches the *backend domain \<\--\> infra domain* mapping for the -duration indicated in the TTL field of the record. - -Due to this caching behaviour, the TTL value of the SRV record dictates -at which intervals remote backends will refresh their mapping of the -local backend\'s backend domain to infra domain. As a consequence a -value in the order of magnitude of 24 hours will reduce the amount of -overhead for remote backends. - -On the other hand in the setup phase of a backend, or when a change of -infra domain is required, a TTL value in the magnitude of a few minutes -allows remote backends to recover more quickly from a change of infra -domain. - -(authorization)= - -### Authorization - -After an incoming connection is authenticated, a second step is required -to ensure that the sending backend is authorized to connect to the -receiving backend. As the backend authenticates using its infra domain, -but the allow list contains backend domains (which is not necessarily -the same) the sending backend also needs to provide its backend domain. - -To make this possible, requests to remote backends are required to -contain a `Wire-Origin-Domain` header, which contains the remote -backend\'s domain. - -While the receiving backend has authenticated the sending backend as the -infra domain, it is not clear that the sending backend is indeed -authorized by the owner of the backend domain to host the Wire backend -of that particular domain. - -To perform this extra authorization step, the receiving backend follows -the process described in {ref}`discovery` and -checks that the discovered infra domain for the backend domain indicated -in the `Wire-Domain` header is one of the Subject Alternative Names -contained in the sending backend\'s client certificate. If this is not -the case, the receiving backend replies with a *discovery error* (see {ref}`authentication-errors`) - -Finally, the receiving backend checks if the domain of the sending -backend is in the {ref}`allow-list` and replies -with an `*authorization error*` (see {ref}`authentication-errors`) if it is not. - - -(allow-list)= - -#### Domain Allow List - -Federation can happen between any backends on a network (e.g. the open -internet); or it can be restricted via server configuration to happen -between a specified set of domains on an \'allow list\'. If an allow -list is configured, then: - -- outgoing requests will only happen if the requested domain is - contained in the allow list. -- incoming requests: if the domain of the sending backend is not in - the allow list, any request originating from that domain is replied - to with an - `authorization error `{.interpreted-text - role="ref"} - -(per-request-authorization)= - -#### Per-request authorization - -In addition to the general authorization step that is performed by the -federator when a new, mutually authenticated TLS connection is -established, the component processing the request performs an -additional, per-request authorization step. - -How this step is performed depends on the API endpoint, the contents of -the request and the context in which it is made. - -See the documentation of the individual {ref}`API endpoints ` for -details. - -(example)= - -### Example - -The following is an example for the message and information flow between -a backend with backend domain `a.com` and infra domain `infra.a.com` and -another backend with backend domain `b.com` and infra domain -`infra.b.com`. - -The content and format of the message is meant to be representative. For -the definitions of the actual payloads, please see the {ref}`federation -API` section. - -The scenario is that the brig at `infra.a.com` has received a user -search request from *Alice*, one of its clients. - -![image](img/federation-flow.png) diff --git a/docs/src/understand/federation/backend-communication.md b/docs/src/understand/federation/backend-communication.md new file mode 100644 index 0000000000..7fa3e71cb9 --- /dev/null +++ b/docs/src/understand/federation/backend-communication.md @@ -0,0 +1,167 @@ +(backend-to-backend-communication)= + +# Backend to backend communication + +We require communication between the {ref}`federator` of one (sending) +backend and the {ref}`federation_ingress` of another (receiving) backend to be both +mutually authenticated and authorized. More specifically, both backends +need to ensure the following: + +- **Authentication** + + Determine the identity (infra domain name) of the other backend. + +- **Discovery** + + Ensure that the other backend is authorized to represent the backend + domain claimed by the other backend. + +- **Authorization** + + Ensure that this backend is authorized to federate with the other backend. + +(authentication)= + +## Authentication + +Authentication between Wire backends is achieved using the mutual +authentication feature of TLS as defined in [RFC +8556](https://tools.ietf.org/html/rfc8446). + +In particular, this means that the ingress of each backend needs to be +provisioned with one or more trusted root certificates to authenticate +certificates provided by other backends when accepting incoming connections. + +Conversely, every *Federator* needs to be provisioned with a client +certificate which it uses to authenticate itself towards other backends. + +Note that the client certificate is required to be issued with the backend\'s +infra domain as one of the subject alternative names (SAN), which is defined in +[RFC 5280](https://tools.ietf.org/html/rfc5280). + +See {ref}`federation-certificate-setup` for technical instructions. + +If a receiving backend fails to authenticate the client certificate, it fails the request +with an `AuthenticationFailure` error. + +(discovery)= + +## Discovery + +The discovery process allows a backend to determine the infra domain of +a given backend domain. + +This step is necessary in two scenarios: + +- A backend would like to establish a connection to another backend + that it only knows the backend domain of. This is the case, for + example, when a user of a local backend searches for a + {ref}`qualified username `, which only includes that user\'s backend\'s backend + domain. +- When receiving a message from another backend that authenticates + with a given infra domain and claims to represent a given backend + domain, a backend would like to ensure the backend domain owner + authorized the owner of the infra domain to run their Wire backend. + +To make discovery possible, any party hosting a Wire backend has to +announce the infra domain via a DNS *SRV* record as defined in [RFC +2782](https://tools.ietf.org/html/rfc2782) with +`service = wire-server-federator, proto = tcp` and with `name` pointing +to the backend\'s domain and *target* to the backend\'s infra domain. + +For example, Company A with backend domain *company-a.com* and infra +domain *wire.company-a.com* could publish + +``` bash +_wire-server-federator._tcp.company-a.com. 600 IN SRV 10 5 443 federator.wire.company-a.com. +``` + +A backend can then be discovered, given its domain, by issuing a DNS +query for the SRV record specifying the *wire-server-federator* service. + +In case this process fails the Federator fails to forward the request with a `DiscoveryFailure` error. + +(dns-scope)= + +### DNS Scope + +The network scope of the SRV record (as well as that of the DNS records +for backend and infra domain), depends on the desired federation +topology in the same way as other parameters such as the availability of +the CA certificate that allows authentication of the *Federation +Ingress*\' server certificate or the *Federator*\'s client certificate. +The general rule is that the SRV entry should be \"visible\" from the +point of view of the desired federation partners. The exact scope +strongly depends on the network architecture of the backends involved. + +(srv-ttl-and-caching)= + +### SRV TTL and Caching + +After retrieving the SRV record for a given domain, the local backend +caches the *backend domain \<\--\> infra domain* mapping for the +duration indicated in the TTL field of the record. + +Due to this caching behavior, the TTL value of the SRV record dictates +at which intervals remote backends will refresh their mapping of the +local backend\'s backend domain to infra domain. As a consequence a +value in the order of magnitude of 24 hours will reduce the amount of +overhead for remote backends. + +On the other hand in the setup phase of a backend, or when a change of infra +domain is required, a TTL value in the magnitude of a few minutes allows remote +backends to recover more quickly from a change of the infra domain. + +(authorization)= + +(allow-list)= + +## Authorization + +After an incoming connection is authenticated the backend authorizes the +request. It does so by verifying that the backend domain of the sender is +contained in the {ref}`domain allow list `. + +Since the request is authenticated only by the infra domain the sending backend +is required to add its backend domain as a `Wire-Origin-Domain` header to the +request. The receiving backend follows the process described in {ref}`discovery` +and verifies that the discovered infra domain for the backend domain indicated +in the `Wire-Origin-Domain` header is one of the Subject Alternative Names +contained in the client certificate used to sign the request. If this is not the +case, the receiving backend fails the request with a `ValidationError`. + +(per-request-authorization)= + +### Per-request authorization + +In addition to the general authorization step that is performed by the +federator when a new, mutually authenticated TLS connection is +established, the component processing the request performs an +additional, per-request authorization step. + +How this step is performed depends on the API endpoint, the contents of +the request and the context in which it is made. + +See the documentation of the individual {ref}`API endpoints ` for +details. + +(federation-back2back-example)= + +## Example + +The following is an example for the message and information flow between +a backend with backend domain `a.com` and infra domain `infra.a.com` and +another backend with backend domain `b.com` and infra domain +`infra.b.com`. + +The content and format of the message is meant to be representative. For +the definitions of the actual payloads, please see the {ref}`federation +API` section. + +The scenario is that the brig at `infra.a.com` has received a user +search request from *Alice*, one of its clients. + +```{image} img/federation-flow.png +:width: 100% +:align: center +``` diff --git a/docs/src/understand/federation/glossary.md b/docs/src/understand/federation/glossary.md deleted file mode 100644 index cfb0cdea6f..0000000000 --- a/docs/src/understand/federation/glossary.md +++ /dev/null @@ -1,108 +0,0 @@ -(glossary)= - -# Federation Glossary - -(glossary_backend)= -## Backend -> A set of servers, databases and DNS configurations together forming one single -> Wire Server entity as seen from outside. This set of servers can be owned and -> administrated by different legal entities in different countries. Sometimes -> also called a Wire \"instance\" or \"server\" or \"Wire installation\". Every -> resource (e.g. users, conversations, assets and teams) exists and is owned by -> one specific backend, which we can refer to as that resource\'s backend - -(glossary_backend_domain)= -## Backend Domain - -> The domain of a backend, which is used to qualify the names and -> identifiers of resources (users, clients, groups, etc) that are local -> to a given backend. See also -> {ref}`consequences-backend-domain` - -(glossary_infra_domain)= - -## Infrastructure Domain or Infra Domain - -> The domain under which the -> `Federator `{.interpreted-text role="ref"} of a -> given backend is reachable (via that backend\'s -> `Ingress `{.interpreted-text role="ref"}) -> for other, remote backends. - -(glossary_federation_ingress)= - -## Federation Ingress - -> Federation Ingress is the first point of contact of a given `backend -> `{.interpreted-text role="ref"} for other, remote -> backends. It also deals with the `authentication`{.interpreted-text -> role="ref"} of incoming requests. See -> `here `{.interpreted-text role="ref"} for more -> information. - -(glossary_federator)= - -## Federator - -> The [Federator]{.title-ref} is the local point of contact for -> `other backend -> components `{.interpreted-text role="ref"} that -> want to make calls to remote backends. It is also the component that -> deals with the `authorization`{.interpreted-text role="ref"} of -> incoming requests from other backends after they have passed the -> `Federation Ingress -> `{.interpreted-text role="ref"}. See -> `here `{.interpreted-text role="ref"} for more information. - -(glossary_asset)= -## Asset - -> Any file or image sent via Wire (uploaded to and downloaded from a -> backend). - -(glossary_qualified-user-id)= -## Qualified User Identifier (QUID) - -> A combination of a UUID (unique on the user\'s backend) and a domain. - -(glossary_qualified-user-name)= -## Qualified User Name (QUN) - -> A combination of a name that is unique on the user\'s backend and a -> domain. The name is a string consisting of 2-256 characters which are -> either lower case alphanumeric, dashes, underscores or dots. See -> [here](https://github.com/wireapp/wire-server/blob/f683299a03207acb505254ff3121213383d0b672/libs/types-common/src/Data/Handle.hs#L76-L93) -> for the code defining the rules for user names. Note that in the -> wire-server source code, user names are called \'Handle\' and -> qualified user names \'Qualified Handle\'. - -(glossary_qualified-client-id)= -## Qualified Client Identifier (QDID) - -> A combination of a client identifier (a hash of the public key -> generated for a user\'s client) concatenated with a dot and the QUID -> of the associated user. - -(glossary_qualified-group-id)= -## Qualified Group Identifier (QGID) - -> The string [backend-domain.com/groups/]{.title-ref} concatenated with -> a UUID that is unique on a given backend. - -(glossary_qualified-conversation-id)= -## Qualified Conversation Identifier (QCID) - -> The same as a `QGID `{.interpreted-text -> role="ref"}. - -(glossary_qualified-team-id)= -## Qualified Team Identifier (QTID) - -> The string [backend-domain.com/teams/]{.title-ref} concatenated with a -> UUID that is unique on a given backend. - -(glossary_display-name)= -## (User) Profile/Display Name - -> The profile/display name of a user is a UTF-8 encoded string with -> 1-128 characters. diff --git a/docs/src/understand/federation/img/federation-apis-flow.png b/docs/src/understand/federation/img/federation-apis-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..faf81be98897896a7493840ef6a67b5dfaff28fc GIT binary patch literal 64907 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

>J z-=8|od{zueDX!)X+$Z-O^5cdJ?G^qz#PAQuQCc75 zCVhCY2u&A(kxGLJM#<&8?-h0bsM7b@{EWO=OgYFk!@+HwdD?Q5g>@q2f>vBjmo>Vh z>(OpuO;K&!U>peJXZZ{XV#UrO=u|^8v1KJdav1 zJOK8fW%hi+G)ViC_cikAIP+AqkZ!ABX2Yfng_0n|PCDc}h<@TcC(ZKuD_GY}sp9sK zP1ardD7-Xo7l)rUya)C!9DUd0P1-A-eBfK^L;WIr3(84`q-$uHSp1=;p+YOuv-POe zOqw5JMb*Bj-EDYgZ)NP}*=XVKglCd`3D)#zR+10^{D)n(Zt!5zZo_Aj=d}D!@w*I4 zN8Nbz=v3Iouhbjz^7GA}B|CN)95E`>ssA2jshweq_2-VhZPzHuM*5=L^#1OP-hhR! z!Kl7UqupUt`yD%Xc2_N5x$@X@o!0gJSxFtF1KVu-Lh4-Tfa(tN`j<3ht@hR?oo$p! zi9TLKE&HW;>yrL9d7EYo-R%^nP|OZ`2X2s(K_gj)fpx zgkVT6$xf%a)eGA>jpsLtl{SPf(o2ECI-7V{vO%Ry7w2HLUOpj+k`=$Vu_mvk6%NeTe=QC?gq4=NT^nLD-|FzLJxD{}vdUQa*394+DcX=0R{2pGRr}=v#OZ^?EPXW1YnrGFKWHY^Kz`|6e zX`m?_7x50;lo!6tP<;L_ONa5W8QGy@`1bQhFqKda6la~1GVj+C2UZunAxULic6C$z zYn)4wIt==QAnQ_tQ$WVa5(dr$Ym;Ck2tKIN1dSZs04WP;dI(}dXy*H{_adb|N#iqz z6(*caNx_tGZwSL612QrN zXgq0C-Bs7HMOg`-?`+gk$?yb@CT8=@b_g6zlS7)Wj1vj*JS;Z0<@QcPx)nMv?V*CX zZOPpRQC@oKoXyb%elq{Y=$2#?G(rW=%^`ym);nn6R8P;EN#1o(n520}ni@~}Sxl89 zg%rv1o^BN%>8=YSORkW-F_&LRCd86gW}F4npxABODZSZF9NCCp_Hiso+>+5 zEtG4D3W^dt&rUpu@{CWlQ>4Yf&16cdwWNgtqGZmg>HK9zPI0>PveyrWsIn#T=SYhJ zRm)n;*_kNgF)%9h1`M!}njTWHa4fhb?PkrP{9RJjj^<`PWUP`VmmvAlZ6H?+xG*=H zN8`r0`o20(nk-^7J!IH0GYv#ihLQW1Kz-&xW!1`@7>G4}oeLl+sAc6cLF~@6D;5uM zaPx9BkA~al5 zq$MsV?*Gk^QE!uUcKh&me~@H#l>!*I!Rs_>dO@D2sgog=-rR{WYOzA07c5!+acnw_SUU2kZJMA0hyof#QCVOevsZ8;3TP=Rfzt*0;mhB`wUyNo z_4DtIrLTfQl)g|D*aD``thReuP2)cIk{5PQ zE@-fDs$tmafqqLud+Npw{9)@jCp~|^7VRfYHd;UW@;@edv}v;O=8(=cxkGyY_NK{( zb$izIdHt!E_w)*PGmTQEDSLC~Xe|-A3*@w>qhd^l5i&yp7DV}zIem}tMwJUmd zf8ucMd@#J>o|QW`ZE8C1mtP9syvb+NhS&D$)oaHUCzZ05k&%(QR~uU!o0+p_t()7w z>XPAPRpd=kNJ&c@nwFMUSyMFt52e|7l-+#n`e5yeQZH)zC4J2A-dSU6`ZctqiIXPb zyZIM;93qe?BV|0yNSB%07HpPBGTaZ~Vd%!0+g&sGs&-dH3>4N6iIga&q#bhiL%-kfmgmlk$7kb`%4<9^C3(%Y|?KP*kxY*6jZ7JFl z8#Zj%9UMI0-96y=@gdFDH>~mu;x1)Z^VIla;E#Efjh_nCUqvM*&R@P<6ByWrNzzsC z@|r$;_;BCrc_ckzwf0QhFgkP; zP;aQ3yE3&|=Rx~|D+dhRW=hs`^Wnou^XBPXx^#)>G}Y1ZDoLMrst_gVv0zwoAUs?r zCN`FBbLiyB10O!D$jZuERb8gnxpU{S-F|p#v_#PohT&du*PUSJ3o6zf75O&2Z%FZ&&C}klUcF{ow5E+^$n6We4AeeTkSZE(UaE z^Fd1{PMOkZ>6=F;1mLk8V$t@lhsKm9{$)E{yXVdeJ0|u}g`1!7DBnzZ@#*L}weDC* z?RsHmkvESfCkOOVH(228d{C|XC*(XXM-goqQ+C(s{g2*ROrPL6KhxQ{nGX%Y@3&;8 zu72O6&}}b_$1VxB)23msJAZ+z>r3MDgADIrwXS7ah&=2K8yaU98ZjU#nwO!WT}2FY z@#5Z~R*{Dfm(D1Db2n)t|2rlord^jVJ%g|PibH1O#*MvRPpSNQUUA2@f7X9pmF8&E zzn6gWYo`$1`o*Bs>)Xim^Pg1f+JCt-s%Do*P5t~L^O=n|e|dSs0bllvMr-%FgY~;d l6L$LBFRSW*@Gb9j>G=^esSGD3eH03n>Ey8qqi6p1e*j@Da_Im7 literal 0 HcmV?d00001 diff --git a/docs/src/understand/federation/img/federation-apis-flow.txt b/docs/src/understand/federation/img/federation-apis-flow.txt new file mode 100644 index 0000000000..5771f1cb4b --- /dev/null +++ b/docs/src/understand/federation/img/federation-apis-flow.txt @@ -0,0 +1,32 @@ +title: Federated request from galley to remote brig + +Galley@a.com -> Federator@a.com: request + +note: +- API: From component to Federator +- `/rpc/b.com/brig/get-user-by-handle` + +Federator@a.com -> Federator@b.com: federated request + + +note: +- API: Federation API +- `Wire-Origin-Domain: a.com` +- `/federation/brig/get-user-by-handle` + +//group: TLS-secured backend-internal channel + + +Federator@b.com -> Brig@b.com: request + +note: +- API: Federator to component +- `Wire-Origin-Domain: a.com` +- `/federation/get-user-by-handle` + + +Brig@b.com -> Federator@b.com: response + +Federator@b.com -> Federator@a.com: response + +Federator@a.com -> Galley@a.com: response diff --git a/docs/src/understand/federation/img/federation-flow.png b/docs/src/understand/federation/img/federation-flow.png index f6558c63df36f7b36f009e74f2f97cd4a0a92305..25a0014e24be2657af54cdd2d8b168ba8a8af233 100644 GIT binary patch literal 142892 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∾ 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

v(&NT8UX?Y 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 P-pYLRuwz4}|M5Qn Federator @infra.a.com: (domain="b.com", component="brig", handle="alice") +Brig @infra.a.com -> Federator @infra.a.com: federated request + +note: +- `/rpc/b.com/brig/get-user-by-handle` +- `{"handle": "alice"}` + + +Federator @infra.a.com -> DNS Resolver: DNS lookup + +note: +`SRV _wire-server-federator._tcp.b.com` -Federator @infra.a.com -> DNS Resolver: DNS query: (service: "wire-server-federator", proto: "tcp", name: "b.com") -DNS Resolver -> Federator @infra.a.com: DNS response: (target: "infra.b.com") +DNS Resolver -> Federator @infra.a.com: DNS response: `infra.b.com` Federator @infra.a.com -> Ingress @infra.b.com: mTLS session establishment @@ -15,36 +24,52 @@ Ingress @infra.b.com -> Federator @infra.a.com: mTLS session establishment respo note: The channel between infra.a.com and infra.b.com is now encrypted and mutually authenticated. -Federator @infra.a.com -> Ingress @infra.b.com: (originDomain="a.com", component="brig", path="get-user-by-handle", body="alice") +Federator @infra.a.com -> Ingress @infra.b.com : request + +note: +- `Wire-Origin-Domain: a.com` +- `/federation/brig/get-user-by-handle` //group: TLS-secured backend-internal channel -Ingress @infra.b.com -> Federator @infra.b.com: (domain= "a.com", client_cert="", component="brig", path="get-user-by-handle", body="alice") +Ingress @infra.b.com -> Federator @infra.b.com: request + cert + +note: +- `X-SSL-Certificate: ` //end -Federator @infra.b.com -> DNS Resolver: DNS query: (service: "wire-server-federator", proto: "tcp", name: "a.com") +Federator @infra.b.com -> DNS Resolver: DNS query -DNS Resolver -> Federator @infra.b.com: DNS response: (target: "infra.a.com") +note: +`SRV _wire-server-federator._tcp.a.com` -//group: TLS-secured backend-internal channel +DNS Resolver -> Federator @infra.b.com: DNS response: `infra.a.com` -note: Check that the content of the _target_ field in the DNS response is one of the SANs in the client cert and that the content of the _domain_ field is on the allow list. +//group: TLS-secured backend-internal channel -Federator @infra.b.com -> Brig @infra.b.com: (originDomain= "a.com", component="brig", path="federation/get-user-by-handle" handle="alice") +note: +Check that +- that the `infra.a.com` is listed as one of SANs in the client cert +- `a.com` is in the allow list +Federator @infra.b.com -> Brig @infra.b.com: request -note: Perform per-request authorization. +note: +- `Wire-Origin-Domain: a.com` +- `/federation/get-user-by-handle` +- `{"handle": "alice"}` -Brig @infra.b.com -> Federator @infra.b.com: (UserProfile(Alice)) +note: Brig perform per-request authorization. -Federator @infra.b.com -> Ingress @infra.b.com: (UserProfile(Alice)) +Brig @infra.b.com -> Federator @infra.b.com: response: alice's user profile +Federator @infra.b.com -> Ingress @infra.b.com: response: alice's user profile //end -Ingress @infra.b.com -> Federator @infra.a.com: (UserProfile(Alice)) +Ingress @infra.b.com -> Federator @infra.a.com: response: alice's user profile note: Via the encrypted, mutually authenticated channel. -Federator @infra.a.com -> Brig @infra.a.com: (UserProfile(Alice)) +Federator @infra.a.com -> Brig @infra.a.com: response: alice's user profile diff --git a/docs/src/understand/federation/index.md b/docs/src/understand/federation/index.md index 48e78ea649..a1dc6b6cfa 100644 --- a/docs/src/understand/federation/index.md +++ b/docs/src/understand/federation/index.md @@ -2,23 +2,29 @@ # Wire Federation -Wire Federation, once implemented, aims to allow multiple Wire-server {ref}`backends ` to federate with each other. That means that a user 1 registered on backend A and a user 2 registered on backend B should be able to interact with each other as if they belonged to the same backend. +Wire Federation aims to allow multiple Wire-server +{ref}`backends ` to federate with each other: Users on on +different backends are be able to interact with each other as if they +are on the the same backend. -```{note} -Federation is as of January 2022 still work in progress, since the implementation of federation is ongoing, and certain design decision are still subject to change. Where possible documentation will indicate the state of implementation. +Federated backends are be able to identify, discover and authenticate +one-another using the domain names under which they are reachable via the +network. To enable federation, administrators of a Wire backend can decide to +either specifically list the backends that they want to federate with, or to +allow federation with all Wire backends reachable from the network. See +{ref}`configure-federation`. -Some sections of the documentation are still incomplete (indicated with a 'TODO' comment). Check back later for updates. +```{note} +The Federation development is work in progress. ``` -% comment: The toctree directive below takes a list of the pages you want to appear in order, -% and '*' is used to include any other pages in the federation directory in alphabetical order - ```{toctree} -:glob: true -:maxdepth: 2 -:numbered: true - -introduction +--- +maxdepth: 2 +numbered: true +glob: true +--- architecture +backend-communication * ``` diff --git a/docs/src/understand/federation/introduction.md b/docs/src/understand/federation/introduction.md deleted file mode 100644 index 02e4d057a8..0000000000 --- a/docs/src/understand/federation/introduction.md +++ /dev/null @@ -1,45 +0,0 @@ -(introduction)= - -# Introduction - -Federation is a feature that allows a collection of Wire backends to -enable the establishment of connections among their respective users. - -(goals)= - -## Goals - -If two Wire backends A and B are *federated*, the goal is for users of -backend A to be able to communicate with users of backend B and -vice-versa in the same way as if they were both part of the same -backend. - -Federated backends should be able to identify, discover and authenticate -one-another using the domain names under which they are reachable via -the network. - -To enable federation, administrators of a Wire backend can decide to -either specifically list the backends that they want to federate with, -or to allow federation with all Wire backends reachable from the -network. - -Federation is facilitated by two backend components: the *Federation -Ingress*, which, as the name suggests, acts as ingress point for -federated traffic and the *Federator*, which acts as egress point and -processes all ingress requests from the Federation Ingress after the -authentication step. - -(non-goals)= - -## Non-Goals - -We aim to integrate federation into the Wire backend following a -step-by-step process as described in the -{ref}`federation-roadmap`. -Early versions are not meant to enable a completely open federation, but -rather a closed network of federated backends with a restricted set of -features. - -The aim of federation is not to replace the existing organizational -structures for Wire users such as teams and groups, but rather to -complement them. diff --git a/docs/src/understand/federation/replace.sh b/docs/src/understand/federation/replace.sh deleted file mode 100644 index 7b4da2997f..0000000000 --- a/docs/src/understand/federation/replace.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env sh - -set -x - -for f in *.rst; do - if [ "$f" = "index.rst" ]; then - continue; - fi - pandoc -f rst -t commonmark_x < "$f" > "${f%.rst}.md" - rm "$f" -done diff --git a/docs/src/understand/federation/roadmap.md b/docs/src/understand/federation/roadmap.md deleted file mode 100644 index 87fb85834a..0000000000 --- a/docs/src/understand/federation/roadmap.md +++ /dev/null @@ -1,112 +0,0 @@ -(federation-roadmap)= - -# Implementation Roadmap - -Internally at Wire, we have divided implemention of federation into -multiple milestones. Only the milestone on which implementation has -started will be shown here (as later milestones are subject to internal -change and re-ordering) - -(m1-federation-with-proteus-mvp)= - -## M1 federation with proteus MVP - -The first milestone **M1** is a minimum-viable-product that allows users -on different Wire backends to send textual messages to users on other -backends. - -M1 included support for: - -- user search -- creating group conversations -- message sending -- visual UX for showing federation. -- a way for on-premise (self-hosted) installations of wire to try out - this implementation of federation by explicitly enabling it via - configuration flags. -- Android, Web and iOS will be supported -- server2server discovery and authentication -- a way to specify an allow list of backends to federate with - -(m2-federation-with-callingconferencing-and-assets)= - -## M2 federation with calling/conferencing and assets - -The second milestone **M2** focused on: - -- federated calling -- federated conferencing -- basic federated asset support. - -**M2** also incorporated a previous interim release which added the -following in a federated environment: - -- likes -- mentions -- read receipts and delivery notifications -- pings -- edit and delete messages - -Caveats: - -- Message delivery guarantees are weak if any backends are temporarily - unavailable. -- If any backends are unavailable, data inconsistencies may occur. -- Federation with the production cloud version of wire.com is not yet - supported. -- Federated conferencing requires an SFT in each domain represented in - the conversation. The caller\'s SFT is the \"anchor\" SFT, to which - federated SFTs connect: - - SFTs must have valid certificates suitable for mutual - authentication with federated SFTs. - - Currently all video streams are exchanged between the anchor SFT - and each federated SFT. The SFTs select the relevant streams for - each client as today, but inter-SFT traffic could use - substantially more bandwidth than an SFT to client stream. - - The administrator needs to open ports between their SFTs and - federated SFTs for signalling and media. -- Assets will be stored on the backend of the sender and fetched via - the sender\'s backend with every access (there is no caching on a - federated domain). If federated domains have different policies for - allowed asset types or sizes, a user may receive notification of an - asset which it is not allowed to fetch or view. - - -```{note} -A rough (Backend) Implementation Status as of January 2022: - -Tested in M2 scope: - -- Federator as Egress, and Ingress support to allow - backend-backend communication -- Long-running test environments -- Backend Discovery via SRV records -- Backend allow list support -- User search via exact handle -- Get user profile, user clients, and prekeys for their clients -- Create conversation with remote users -- Send a message in a conversation with remote users -- Server2server authentication -- connections -- Assets -- Calling -- Conferencing - -Partially done: - -- client-server API changes for federation -- Other conversation features (removing users, archived/muted, - \...) -``` - -(additional-milestones)= - -## Additional Milestones - -Some additional milestones planned include the following features: - -- support more features (guest users, bots, \...) -- support better message delivery guarantees -- federation API versioning strategy -- support for wire-server installations to federate with wire.com -- MLS support From 4bcf4ea76c1e8f43cff4066d0ae77f20c06923a9 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Thu, 12 Jan 2023 17:58:55 +0100 Subject: [PATCH 24/33] Inline "Administrator's Guide" one level and integrate post-install in the install guide --- docs/src/how-to/administrate/index.md | 2 +- docs/src/how-to/index.md | 20 ------- docs/src/how-to/install/index.md | 3 +- .../post-install.md} | 57 ++++++++++++++++++- docs/src/how-to/post-install/index.md | 15 ----- docs/src/how-to/post-install/ntp-check.md | 44 -------------- docs/src/index.md | 6 +- 7 files changed, 62 insertions(+), 85 deletions(-) delete mode 100644 docs/src/how-to/index.md rename docs/src/how-to/{post-install/logrotation-check.md => install/post-install.md} (52%) delete mode 100644 docs/src/how-to/post-install/index.md delete mode 100644 docs/src/how-to/post-install/ntp-check.md diff --git a/docs/src/how-to/administrate/index.md b/docs/src/how-to/administrate/index.md index 5f6dd1ab72..79a04fa649 100644 --- a/docs/src/how-to/administrate/index.md +++ b/docs/src/how-to/administrate/index.md @@ -1,4 +1,4 @@ -# Administrate components after successful installation +# Administration ```{toctree} :glob: true diff --git a/docs/src/how-to/index.md b/docs/src/how-to/index.md deleted file mode 100644 index 46bf098a9a..0000000000 --- a/docs/src/how-to/index.md +++ /dev/null @@ -1,20 +0,0 @@ -# Administrator's Guide - -Documentation on the installation, deployment and administration of Wire -server components. - -```{warning} -If you already installed Wire by using `poetry`, please refer to the -[old version](https://docs.wire.com/versions/install-with-poetry/how-to/index.html) of -the installation guide. -``` - -```{toctree} -:glob: true -:maxdepth: 2 - - How to install wire-server - How to verify your wire-server installation - How to administrate servers after successful installation - How to connect the public wire clients to your wire-server installation -``` diff --git a/docs/src/how-to/install/index.md b/docs/src/how-to/install/index.md index 183215c603..0b4119e9ee 100644 --- a/docs/src/how-to/install/index.md +++ b/docs/src/how-to/install/index.md @@ -1,4 +1,4 @@ -# Installing wire-server +# Installation ```{toctree} :glob: true @@ -27,4 +27,5 @@ How to install and set up Legal Hold Managing authentication with ansible Using tinc Troubleshooting during installation +Verifying your installation ``` diff --git a/docs/src/how-to/post-install/logrotation-check.md b/docs/src/how-to/install/post-install.md similarity index 52% rename from docs/src/how-to/post-install/logrotation-check.md rename to docs/src/how-to/install/post-install.md index 17bcdde7db..6a513f0ece 100644 --- a/docs/src/how-to/post-install/logrotation-check.md +++ b/docs/src/how-to/install/post-install.md @@ -1,12 +1,63 @@ +# Verifying your installation + +After a successful installation of wire-server and its components, there are some useful checks to be run to ensure the proper functioning of the system. Here's a non-exhaustive list of checks to run on the hosts: + + +(ntp-check)= + +## NTP Checks + +Ensure that NTP is properly set up on all nodes. Particularly for Cassandra **DO NOT** use anything else other than ntp. Here are some helpful blogs that explain why: + +> - +> - + +### How can I see if NTP is correctly set up? + +This is an important part of your setup, particularly for your Cassandra nodes. You should use `ntpd` and our ansible scripts to ensure it is installed correctly - but you can still check it manually if you prefer. The following 2 sub-sections explain both approaches. + +#### I used your ansible scripts and prefer to have automated checks + +Then the easiest way is to use [this ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/cassandra-verify-ntp.yml) + +#### I am not using ansible and like to SSH into hosts and checking things manually + +The following shows how to check for existing servers connected to (assumes `ntpq` is installed) + +```sh +ntpq -pn +``` + +which should yield something like this: + +```sh + remote refid st t when poll reach delay offset jitter +============================================================================== + time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 ++ 2 u 498 512 377 0.759 0.039 0.081 +* 2 u 412 512 377 1.251 -0.670 0.063 +``` + +if your output shows \_ONLY\_ the entry with a `.POOL.` as `refid` and a lot of 0s, something is probably wrong, i.e.: + +```sh + remote refid st t when poll reach delay offset jitter +============================================================================== + time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 +``` + +What should you do if this is the case? Ensure that `ntp` is installed and that the servers in the pool (typically at `/etc/ntp.conf`) are reachable. + + (logrotation-check)= -# Logs and Data Protection checks +## Logs and Data Protection checks On Wire.com, we keep logs for a maximum of 72 hours as described in the [privacy whitepaper](https://wire.com/en/security/) We recommend you do the same and limit the amount of logs kept on your servers. -## How can I see how far in the past access logs are still available on my servers? +### How can I see how far in the past access logs are still available on my servers? Look at the timestamps of your earliest nginz logs: @@ -23,7 +74,7 @@ If the timestamp is more than 3 days in the past, your logs are kept for unneces You can use [the kubernetes_logging.yml ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/kubernetes_logging.yml) -### I am not using ansible and like to SSH into hosts and configure things manually +#### I am not using ansible and like to SSH into hosts and configure things manually SSH into one of your kubernetes worker machines. diff --git a/docs/src/how-to/post-install/index.md b/docs/src/how-to/post-install/index.md deleted file mode 100644 index 2dd2009af9..0000000000 --- a/docs/src/how-to/post-install/index.md +++ /dev/null @@ -1,15 +0,0 @@ -(checks)= - -# Verifying your wire-server installation - -After a successful installation of wire-server and its components, there are some useful checks to be run to ensure the proper functioning of the system. Here's a non-exhaustive list of checks to run on the hosts: - -NOTE: This page is a work in progress, more sections to be added soon. - -```{toctree} -:glob: true -:maxdepth: 1 - - Verifying NTP - Verifying data retention for logs don't exceed 72 hours -``` diff --git a/docs/src/how-to/post-install/ntp-check.md b/docs/src/how-to/post-install/ntp-check.md deleted file mode 100644 index f093998eea..0000000000 --- a/docs/src/how-to/post-install/ntp-check.md +++ /dev/null @@ -1,44 +0,0 @@ -(ntp-check)= - -# NTP Checks - -Ensure that NTP is properly set up on all nodes. Particularly for Cassandra **DO NOT** use anything else other than ntp. Here are some helpful blogs that explain why: - -> - -> - - -## How can I see if NTP is correctly set up? - -This is an important part of your setup, particularly for your Cassandra nodes. You should use `ntpd` and our ansible scripts to ensure it is installed correctly - but you can still check it manually if you prefer. The following 2 sub-sections explain both approaches. - -### I used your ansible scripts and prefer to have automated checks - -Then the easiest way is to use [this ansible playbook](https://github.com/wireapp/wire-server-deploy/blob/develop/ansible/cassandra-verify-ntp.yml) - -### I am not using ansible and like to SSH into hosts and checking things manually - -The following shows how to check for existing servers connected to (assumes `ntpq` is installed) - -```sh -ntpq -pn -``` - -which should yield something like this: - -```sh - remote refid st t when poll reach delay offset jitter -============================================================================== - time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 -+ 2 u 498 512 377 0.759 0.039 0.081 -* 2 u 412 512 377 1.251 -0.670 0.063 -``` - -if your output shows \_ONLY\_ the entry with a `.POOL.` as `refid` and a lot of 0s, something is probably wrong, i.e.: - -```sh - remote refid st t when poll reach delay offset jitter -============================================================================== - time.example. .POOL. 16 p - 64 0 0.000 0.000 0.000 -``` - -What should you do if this is the case? Ensure that `ntp` is installed and that the servers in the pool (typically at `/etc/ntp.conf`) are reachable. diff --git a/docs/src/index.md b/docs/src/index.md index c5b3d5b4db..135a0aa03f 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -23,7 +23,11 @@ This documentation may be expanded in the future to cover other aspects of Wire. :maxdepth: 1 Release notes -Administrator's Guide + +Installation +Administration +Connecting Wire Clients +Optional Configuration Understanding wire-server components Single-Sign-On and user provisioning Client API documentation From 40201f59d0a476a9f0132d5df219a6887de8d7ab Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Thu, 12 Jan 2023 18:10:16 +0100 Subject: [PATCH 25/33] Move configuration options out of install --- docs/src/{how-to/install => }/configuration-options.md | 0 docs/src/how-to/install/index.md | 1 - 2 files changed, 1 deletion(-) rename docs/src/{how-to/install => }/configuration-options.md (100%) diff --git a/docs/src/how-to/install/configuration-options.md b/docs/src/configuration-options.md similarity index 100% rename from docs/src/how-to/install/configuration-options.md rename to docs/src/configuration-options.md diff --git a/docs/src/how-to/install/index.md b/docs/src/how-to/install/index.md index 0b4119e9ee..2758ad819a 100644 --- a/docs/src/how-to/install/index.md +++ b/docs/src/how-to/install/index.md @@ -15,7 +15,6 @@ dependencies (production) How to install wire-server using Helm (production) How to monitor wire-server (production) How to see centralized logs for wire-server -(production) Other configuration options Server and team feature settings Messaging Layer Security (MLS) Web app settings From 460e3b8cace832e401944abe73b8b2b1c8b989ff Mon Sep 17 00:00:00 2001 From: fisx Date: Fri, 13 Jan 2023 10:40:59 +0100 Subject: [PATCH 26/33] Fedcalls cli tool. (#2973) * fedcalls cli tool. * Cleanup * Output both dot and csv. * Fixup * Fixup * Changelog. * Fixup * Fixup * Update tools/fedcalls/README.md --- cabal.project | 7 +- changelog.d/4-docs/pr-2973 | 1 + nix/local-haskell-packages.nix | 1 + tools/fedcalls/.ormolu | 1 + tools/fedcalls/README.md | 31 +++++ tools/fedcalls/default.nix | 38 ++++++ tools/fedcalls/example.png | Bin 0 -> 110931 bytes tools/fedcalls/fedcalls.cabal | 74 +++++++++++ tools/fedcalls/src/Main.hs | 220 +++++++++++++++++++++++++++++++++ 9 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 changelog.d/4-docs/pr-2973 create mode 120000 tools/fedcalls/.ormolu create mode 100644 tools/fedcalls/README.md create mode 100644 tools/fedcalls/default.nix create mode 100644 tools/fedcalls/example.png create mode 100644 tools/fedcalls/fedcalls.cabal create mode 100644 tools/fedcalls/src/Main.hs diff --git a/cabal.project b/cabal.project index c9f7daf5a0..b1c4c70161 100644 --- a/cabal.project +++ b/cabal.project @@ -42,13 +42,14 @@ packages: , tools/api-simulations/ , tools/db/assets/ , tools/db/auto-whitelist/ - , tools/db/migrate-sso-feature-flag/ - , tools/db/service-backfill/ , tools/db/billing-team-member-backfill/ , tools/db/find-undead/ + , tools/db/inconsistencies/ + , tools/db/migrate-sso-feature-flag/ , tools/db/move-team/ , tools/db/repair-handles/ - , tools/db/inconsistencies/ + , tools/db/service-backfill/ + , tools/fedcalls/ , tools/rex/ , tools/stern/ diff --git a/changelog.d/4-docs/pr-2973 b/changelog.d/4-docs/pr-2973 new file mode 100644 index 0000000000..89fbeb8be6 --- /dev/null +++ b/changelog.d/4-docs/pr-2973 @@ -0,0 +1 @@ +Tool for dumping fed call graphs (dot/graphviz and csv); see README for details \ No newline at end of file diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix index 387f117aa1..aea935c787 100644 --- a/nix/local-haskell-packages.nix +++ b/nix/local-haskell-packages.nix @@ -51,6 +51,7 @@ move-team = hself.callPackage ../tools/db/move-team/default.nix { inherit gitignoreSource; }; repair-handles = hself.callPackage ../tools/db/repair-handles/default.nix { inherit gitignoreSource; }; service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { inherit gitignoreSource; }; + fedcalls = hself.callPackage ../tools/fedcalls/default.nix { inherit gitignoreSource; }; rex = hself.callPackage ../tools/rex/default.nix { inherit gitignoreSource; }; stern = hself.callPackage ../tools/stern/default.nix { inherit gitignoreSource; }; } diff --git a/tools/fedcalls/.ormolu b/tools/fedcalls/.ormolu new file mode 120000 index 0000000000..157b212d7c --- /dev/null +++ b/tools/fedcalls/.ormolu @@ -0,0 +1 @@ +../../.ormolu \ No newline at end of file diff --git a/tools/fedcalls/README.md b/tools/fedcalls/README.md new file mode 100644 index 0000000000..e43f95e14a --- /dev/null +++ b/tools/fedcalls/README.md @@ -0,0 +1,31 @@ +our swaggger docs contain information about which end-points call +which federation end-points internally. this command line tool +extracts that information from the swagger json and converts it into +two files: dot (for feeding into graphviz), and csv. + +### try it out + +``` +cabal run fedcalls +ls wire-fedcalls.* # these names are hard-coded (sorry!) +dot -Tpng wire-fedcalls.dot > wire-fedcalls.png +``` + +`dot` layouts only work for small data sets (at least without tweaking). for a better one paste into [sketchvis](https://sketchviz.com/new). + +### links + +- `./example.png` +- https://sketchviz.com/new +- https://graphviz.org/doc/info/lang.html +- `/libs/wire-api/src/Wire/API/MakesFederatedCall.hs` + +### swagger-ui + +you can get the same data for the public API in the swagger-ui output. just load the page, open your javascript console, and type: + +``` +window.ui.getConfigs().showExtensions = true +``` + +then drop down on things like normal, and you'll see federated calls. diff --git a/tools/fedcalls/default.nix b/tools/fedcalls/default.nix new file mode 100644 index 0000000000..1fa52660c6 --- /dev/null +++ b/tools/fedcalls/default.nix @@ -0,0 +1,38 @@ +# WARNING: GENERATED FILE, DO NOT EDIT. +# This file is generated by running hack/bin/generate-local-nix-packages.sh and +# must be regenerated whenever local packages are added or removed, or +# dependencies are added or removed. +{ mkDerivation +, aeson +, base +, containers +, gitignoreSource +, imports +, insert-ordered-containers +, language-dot +, lib +, swagger2 +, text +, wire-api +}: +mkDerivation { + pname = "fedcalls"; + version = "1.0.0"; + src = gitignoreSource ./.; + isLibrary = false; + isExecutable = true; + executableHaskellDepends = [ + aeson + base + containers + imports + insert-ordered-containers + language-dot + swagger2 + text + wire-api + ]; + description = "Generate a dot file from swagger docs representing calls to federated instances"; + license = lib.licenses.agpl3Only; + mainProgram = "fedcalls"; +} diff --git a/tools/fedcalls/example.png b/tools/fedcalls/example.png new file mode 100644 index 0000000000000000000000000000000000000000..26bc63134fc15f6e76dcc857c138989e2a94a0ca GIT binary patch literal 110931 zcmeFZbx@wmvM>A+f;+(_5Fog_ySuvtcX#*TA-FpPclQJd?iSqL-QhmTT6^t%>fWkb zr|P>^-yi2mAiVSR^i22k^mPAvNSM5=7y>K~EC>WbkPsJE1c4ygK%h6f&~Jbi$?^bY z5a=DJhqAhpqP{DkorA53xs@@Yle?WUp|P8}2?*r2P>^csg4>!H{A!5C^EN+_9~#0C(qW+VU6#j>qn2pWU^j2 zMe)qO_zg=i3Ew=aKqR7Q29z&ja~XZ<51#aIE|k)FeVaygXjop|Cg?Qoy{>7r>N^at zc(gb==pZW=fjtq z&z>`|Z<~0Ekt&~l7JaIPH0H5NNIJ-;#l04NTY45X}OUo9GpbzG>vMDk(Q`fNm^COVv%fii;{Vj z(t_Cqi^{x$#I*WJ3(NAx2}3!*Z`YHSw(YkJ$&U0FYbQS-42RjLhNqUOYe!ih@ggoX z+~+AvD;gefxK8SRMC-ULTi^1?vd2#j^vQBu*G-Kz#V^R5>{vD~c`P2gdYK$&%&z*p z5?QWi)ZZCNV$P10ou7c?jv))!84O~>M9@Mp>L$E_<)k$22u{yqEKI+DzYiNhJsDq% zAJ4#;EEnq1Eq0>qWvra?=({~K^CY#h@yg;TTl_dQ{G8dCRns6MbU5&<6b@ZEYp+9T zQO91FNYF*nvaWx03Eie8>6`W31!bKZtY{V{V<0S)2L03cB5FxMZ2;^C%%jNP4YY)zVUaLMn-_`BWl5 zDw8682KmwM!D8ELb`BQXaAbU9px*CTf0?Pavo<~9A!8p;-Dj?fpP>2>|3!i`z0S=vmFNdHxocAS=Ri?^#}V`G|%&|5oIm+(*X zC$UqbQkEBGG>FzhVo;7@)m}X@s^Ls68ss)N%=uK1Aw@XBE>=y{7CF0D1 zWXf_xHn_wnI1%;uy3CZiABBx8qj5^OqUsN|L#$A)VU_c>$ai4;b>V7Rd-wUOp#88W*kwhSy9=YIkpj1sV)kS2%~ED#$D`cYn$F<~_;jeB0NqsM~@|>wQwF zzv;$0ORW^%hUXM#p&R_5+^#gQweVrlpnkjX*s}OE_{E*_R?`~4^2?|8)y*d~BTtiO zq4cbqQg@cmy2EE>8<0c3sr~JyEqPh28>US!SVw#^+q}CEbMnv0 za~De^MkiVpd;tllW&!+$pIN99bV#Yw_@(ndhH^4Jq+i2beZ8m*c!JpP0MkLg>U^W# zRSFs&_mu;a7fwU(oRst0iF9VbmUKrY=TF!nv4hDKkzDvvrv&4PpC))YaAx-YGg2PL z`YUIZc&W=LrlMoe>KfeJ7VEGOPrAPB<+u2C^nF;*0j}CBM9*D>E0CW^rXBORA89^8 zW{6x(7Ws>YFi8%aDMa*;?rGmTdM2TQyu=oOvFUN{W z#&Dkc=JB&T^BS}ZN1^l*oGS<48vAtDN3&}9rhG6d4tlKbTOlmyAkC+$UEE$MhlH`Q z7`ZwgHWcSn3S6`izpbS;8;X$g67|px;S(t&OsiT7Wg^@5py6^qjxGI(Iu;kvUX@R+ zIl2ceMtFgX~ZPq3{%Np!s# zQF_frKIz`0Wi2Mmq@4I%!OMvY6br^`P~A}Q(?OyAy}7>|KkgDhExg6mWS^rk z4*jd{XTO%ABVa*Wi``+IY^0_ZgONLA-)axLD!^jvSLF|JSIy->Nw{W{F9jx;z5T=w zJKJ88vLj5akanX;DemIW_fk71-lpFmT#GI&XoaN_|Bj_Ke3%01YzR5%>FMa@IDn70 zyJ*utbiS09MKC zEPTsukdzO_7yqMKbgYWof>-X{pjIM8#1C@1=jIcQ?=;8UbON?Duc%KWFSRG83yTv94hGw8J~TiF%;wM%N?k-T zp8ER`hMgG9`7KR^qEI~Gqe|~ z(msx2En-Uzu_*dR7KI7F2BB8I^gHe|4PKA`4T@*qcdf`U57InBmt_c#kw)jn+ z5nbU&a)Q-^`zMzZ8--FP64+(kt?$O4Wm^m1BJ#KhLdn}E*wbFgy@#U#n?>Y*9Nd

Tbxy{$pAmxnVa6KLeBbjq+C zMZ&Rl@2KWd5Y{Ji5D+4xmY9aLNIY&6E7S+fx^>CeXQKvkVkZ0*tFEkC4zKW09{Ou} z1_*DG4E!KNazyur+TdV6mgSU4oF`Ya#9Uy6?4?DZ3=9tRr$|#Fa}LUdjF{cDQMT37 z2E2nDvyu~A1_P^x3`fAJ3z+QtSl_fyXCONW#Tw=%j#y2O&k3)Q->fAvysL{KU*WJ~ zH`iv}tVvAdqi}+SX7#3&v4vr7J`2TTAlM^%$TYqG@JKF1?g}*%%P^MgqTDv=_5&^c zKwZFS5xINGm+vrX1?3uS=pguRImbj8B{{TtA`bineT$~aXu~)TQf=bwxDKOCV|aYT zh5*p}r9caXBjghWU#M`1dOxH1JgjAZ&Dy*ZjXOaBT1!qpiis@diK$7H@BE$D9~2^d z!dDxGu~d|ZsBvR5R;=;kg1t&Vt*^lnsQZPxblFmOkq`%a1|f3hI$OWJ*Vf&dN6D0` zh|_d{5uKX&h=oArhfs`pd39`n-Z)06EwCk@9N;daIl!(bG@@Dj9Dh2PTirZfZtr7o@LM_Qq)7sX65IR7U%gp1igrX{?9^5bk(VI3NG=0Suf zNbD*Ye|$mi?}284rG+l`;{k+uozqBkRk>SQ4EiVXPBU*Uj7}TGztO==NwG{zz~vaH zJqaX61xZ(bT__(08xVf?6Qn7uhg&-F!Vw+MoHjq3@S1{{#BL z9XSu$OBuow8Rf(6cRE!(=R|DML7~-dYyGkg8S<#aAB*M+Zi%GK)fZog*)mYxa!BCo@#o2A$TerP!-phSXeaS&icSv) zlK5=aWOjpVLwL+!BtIQ?7GvC|P2GtUY`;Jd-!q@$JWWj&x(ovc9F8^Kmu@5G1Tr0F zoWUKZTs4#p=$AL{JD6+&ebDv}pTE++e6F{-Hm!EKf`aJdhhrP7H}i}Flgv#t8%;Hb z_#WpZ*@WW4*|;bDNZ2|~LERDBQ=x6AZnGf>YJwC*LKE9-eot`u^UmC}2qB+%W8@RZ zA-wn&TR2}4RSexUJQ(@hIvFZ5UocB@UMW`Jg4Ov#;eAsKI+PYfu4{3IvEUpWJ@bKB z2dWzlAqfp*yw!L@GR6U-S448Bd@3SqF;i<%m|tB1oH|=On4_3FhFa%LbQ33Jtdva& z!Uy;?-ise!y{#?xmZoz%zUC_lUa?{F7tn=5-Qv2yg2SqZq5Hp~#^#z$L+xK!TrPYp z#C9^GosO@HLZ}SmI)tWL%O@Te>}C20ZCkfiQgwfz1rOSXkGs!FzRD1PN0@TfEa4bq zH+&bX@`dZu<5$PN#I}xuq7S(J2|aNTL~6ghKmWMSke1qepyoq1hrkzVlT>k_*2m4V zRl-rJu5is37=7nCpTgUm2DLWj4#G5RPF zgkN%JDEqlkF(%eURv!HXn@F?mj@Dx=uGq2jaEaDh9N*~Tnn6Yg(**@?$bCeA64*rW ztVPWbbL;n+PJ$xGAa;e@47?Mu>>ecigkAmVsp10BPEq|Uwv@k;SwOcsy_X-~vBwI-gj zI10;SP45$pMF@K*6ymqQ${>38jV#`J`EpdF@@CLY&s?>OE%?23`>mM2j}mycT@H^(cvoMVSr64XSBw(z3` zo5mv&z$}s z0`Ba_zP3a53GcSMefpgzh!0I!SnHM0V2s&{U;EH-lyNzohPra!ijVtJ2zb$Ws|i21lq3zL`Tzp$LC-9@LZ8ygeN|b zsQw(!Uq&~v8gpD=&;P|n+4@~FvVpcrD^bF8!sA)r8*)^LXw<{r2`bppsbj9%hZ$`B zr&3DOn<|J)5{!=bw-hZ~-*#jlCW*7i+|bItR&HTSg)<-HIcJ6|^?J|wqx4r+Qn6Lw zb5|a5v^&|Fy)!Q@pMoLy8A4A!F~9_=a~;^;Ligy&C#&lU0APn=??_lt-j4` zwa?t*wuG#665LOI*SSAP`uPxsZ^&gpkm`P&5ELOZSf95%1>5>(x^#$VC^R z3MDrxlEn}f!K;TADMGdgR>ha5eGVW+kO+^+Thp{VGps&X+bAmyEG2~Y@nh%YplA^f zdzFHvG2mf8>1*|jZgusOr(qniz=dL7W_?#(lq^{p?yvTCBAOOHPt7EeflR`_4o~m) z?EIRxt5!4X(b1_CI|7V1#w`*1n@pPkce^=m`fZpHHC<`XZjT|U&eyw`!|*=abVph^ zZYCqrcb7dM3>fBmB;VDx6{EE~K0@c}CUlaFESCJmJu9JPV z4;uLwgavxvJ+Djp0?e8(WX09zPGtxaUWrQPx-*pVS_ExvHfCd_rX?`QO!)bDodG$e zeiQ#&riX9LGnSvPbg&TU`)tN%S=m8BjRT6!brpmOUUhDWWuQ^Ec!PK;1ds# znUj+pCmo%ut1GQ56RoX-DIEg`2L~NJBON0n4M0KT=x*br??z+eNc@|{Up$139St4K z?VQYQZ3ut!)HkqocH$u-0^Sq;Ykt;tGBW?Lw{iSi3xGc8-1P0}7-;F~tgY$(vxlRT zhzr2v?+N`MdpIfs;4qz{v7@cCgQ2mAi?NLp@qe-~GW@5%owI}0A90Kf>5Q$6tpQR; zU{r?xGNhPFF@JXrNEeLSw?rXh_4%YQjXr&dy;( zW2kRrK+kT%#GtQl^dGDwY#g2RZ48ZnvjUjYnge_o*bUh@j93_GSQzvfX_%P|7-`rI zISgr-I2btC8T6UiSQ!ofgN2-fIbfCgR{uGx->i%PRwl;AOw7#81~d$Y1{^fZMg~kY z`bMnmH2U=Ph9+zrhU}~i?0;Ap8FGr+I#}xi%V}<{Z)!|uXJh*3#czdk3dl?F5HZry z|GP!rO5e!@=)glHZEoZ2_U{+U=GMkaPWr#K$-v6Sz{<$Tz`?}EN)OEX-$bg$4vv5& z{^rR*Ps_yk=gseB;RJ*M6s!N+P5}mg+5uTOg&d6aoopSHZEdZ1h<;Cr@ORIDUdsU6 z$w=QxUs&JC7+^}z$jHgS$jQi{{QElxCo=~XJp(8Gf6&_+nVY!(->83Y9zyQFmR#K2 z5g6b7Pt#vJO3B#%uTOt{v@-v*l?Vy{Yzj_&!@opu)ORudYrO$pf4wp^)3-4-2E51L z=K8O8^Z$b>u(KF&Ft8bM&=|9_Gtn?Jny}L7>vNa@Mq$Lv#$ss1#`d>V|8RG-HF0v) zcQ6((1@s8$3NX(>-vZP9_8I?@ zn49i@(+T$iQpb z{kJsm-y;4W>-ryc{kJsm-y;4W>-zspU9kU7r;KfYD99B^mLmJRjetZ8#!g(r5d?yJ z_xlSBl$wqOG(tH^$cRAgz@x+Av%op&$$&tFAPHdsWw(W+WoLD?#YfPYYeT;dPCiOZ zgIT3NSVPdKjq3$>>xoibt9D9A1MPS3J|al>WW&EzmVXPu0D_3uPW+X+N)UJm8yc*4 z{jxIFec8n2xHCM@#1wJqFv=B_P(VW%ge(aC;m^0L%{$)ke0{oqH{}p~@z?S5@ga&S zs;CepM~fv1Yxn<){vjCo?T=6NMn+qEPMkN8f+OAJ=nwN@!bI<*BhD%QVme+KN+RLU zPlyJKj`jV=-|MDUO4z~HU%*x8ft2R&wlckcGLg|m|(%9w|~!RWg%NH zNn&vppG&h^z073r?EdNTmdT+e>)H2&JW??y3m-B6@5!&1BRmwOBx9jtVnzrPf#})T zZf|Z>m6gjXDiC2{+%L9ZR6ffVrX^$P$HV?5?e8(X>1JlQDTN_GUcA1iEA73#y`-e1 zO5dc% z`&*4c$Papsc6R-vqd$X;C+Q`FUx?Z9MvpjZCnrz86|83Pn5srh7Z+AGnvQkCz*GZ& zJ#U^$3oApOBmdG#t{c+z#m)PGXFuLB!qA^^*Vorjh6heUKnTb&>OY+8jZ}%=maRv-#voyQh?tRHwHd2`EB#SbxjYg;Brt zQ83NLz}Xrkzn+-F=S@#<<$Cv=e(Z(cafi9b)-^bJ6TvDk|8@5g;*I~B@2oIs+<*at zd)vXOGGE%r%{lgbUSVx>GX==Vm?y0LJtm%4z|eMTTAFMoqxp)qUfXw{?C|g!li8d7 z!-Lyf#gE1X+2Bxt0f;K9s&7Cc-Pn9SM|Uurx*fw>wI0XEixPU(tL>hyj`6>UKR=2m z1_eQ?soIgrWj@>|z!NaERaMc`vYzaonsRxzo}D2(GL^Qpv?P{u?Cx*EY?1+H;j6HV ziP53kVD&Nc^^bz~cH_Tdxzg_;gCcQn#TspRcpa%eendZNA#k5FG;MX5VbsydFd0Nc zYt!toQjwHAXgm@Z5b!b6Wv#4|ui2W}8i<|4Lo{7!bMK2LNa9|WTZrnRTVw(Ip8rGB zMgO&WQ{ptGE7ijOsk!Acq=%cfdopu=F=Bf>g1upVW4+r~t$rh7-PC6?|1__k4p*w6 ztZcg7vr#NXgJYiKwN@1e2U}A!xv!5bK1r?4ZTsXT5c6(y+o3cdP>A*377WDX$V8BZ z!kZ`d^my7~K1EtRZ`Bs-tND_~>fwpR;dUnfeU_h+SERVC3?3RfGL)Oc^QP?GhaV{G zF&!Nd9d! zCNe6jfL0v>qSt(?@MNhv@7uQq5y~;QcwX0&r=bZ38XqIul{OUxh4Z*{6Pgv|Oai*2(OM|1JiNWrW3aFDWe5_{?lln~=B;4$ zU0{q9iG<|N^Yz|@kdTmJR}jqfV!h>@X4 zj*H`?L&SSH)ZDx3!h07UV^#bUxkAk6R$rwIS}(_RN`~_mBCYC_H`=s~T64O3YWJAJ z#YM<7rD~P0NgpUd{R5*4s)`3I?OG8~B}>atmD=^gtGP#;e(LqFPg>0$|@=x zc%u=!bVZHUiwzXy{jVPdX+jY3U)*sn;jVH2xTJ@btE+1#XQ#d>TwE0U*l=oL;dDGU z8!Hia;~%AOq>(P-rY0sb+4Z-{`Xrj05gvZx1O639O4K8)s3g3iNF1Uh_s@f4D-ye< zw&bdgqSDQk66*a@qQfcaMwi*_#FsObN=ru7NRjSB?dLnJ=SKHF@%e|kd;g*8QFA_< zM)p}$md!>FMR@oK!d`L7hL7`h7H&aoP*I0HaM4IYLZZI5)_R^FJ#9pHjjK^0RI+C9 z=PU-@nx3GKfWRfhd!E6!ppS^VV$UtvC_%N3(TMnqVImt>P)Mj7@5E!%8J#KP1}?6y z-uhX*r(>n9Fdcgjs`F)0yqB_F^d?!x~p471)SuRJu&U*3Y z>$?v=K2l3OOQj`lX>7Jpk)iR*wEK;tozAQb=5hDrQ?sMGs)^T6_d|P9Nky<=-+%pT zvTUq4I5^**2Bk>5L75>k>1Jg7#*=e#y)!<}1~Ned=8H$bgduch)snPRWL zkMqFXTQ*vHPmfu4&p7@X;apAzjrm)odtB3jU5QBa|ub(~BEiAY1bp+gp00t|z77r-QKnBEoSdISLNL+M(~|4X z&#tIxXlSUZ0UYvZwp?mDrB*~l#KFPg0rEjvpNgu~KM>LeX-O91Gw6Fy4>J!>l*foc zH9P`>A#ESXuk-bxtBc6B@xvRi)t;UpwP%4zwvQhRzy%)N+DayPeU;VJ#xl4TyQjRj z!)N3&x9TiS>wUU8bya49;V@gBoo(nhD}j}R3b;95Zne!hHQ>xxDX_%kY=5E1h4b2l zdi&O99&hc0!R->`H9kJRt1EY^aO|nolPsh==LK?mZ!f#J7zN!~wS}RWqIkzdb=s20 z`#FxR8+F5$c~3upFd*Qjw78T;v*~VsO51RA7LW^+U6JFnclJXHlkVku4pj#~!*{mQ zkUm8U<5k9*#pTW7XX4=|kafB-dw2Lw=E zU7cm9h8eYfs@%^mF5FxCn)QO=V zspQCRvm`SZdsIIUNc)z5GW?Zszr|}d1XGb^BBO0lSPoC;b0BoNPd+n-ocr+Jza*2# z^DR^ebF)Rt>Y%Yta>G65*ZBCYt*u{P4o+lbefjxxLzyRATYIOSU?AC`w`{s;Uy%TIl_Sp<@H7Lv7LFn`e-UBFo3qUxJO1ut7{v%TyOBi!jzPh z9yfa7a^%%jRIY&@C3eov*N1bJBym~W=xBBI^>P_yzJoc{)%ND*6bVNXVrsWnDYavn zJm7v$Pe_n?<`xzwd{w&xn@7!#yKupR8XEHwab&V7j65HB!9W+6m(nBBVv$%b>s`G+ z%QH9}AGU`WaM`SKR{)4!LNz!9`12X;`;Ls3x0mSr{9*=|CoeB==1}>;Tqi;OV0ZWN zPE=ZKw9@N{Y^*Y?D+I)S23O-p(v@HJhpRvPhE{%b*k*w#AN*)*dr%Tn0h-j2Ye zLx&XsA|FfDN`$5NYn}E=8X6oRZjbn8_e)if+tc0ouj71^8w zCQI)WrcJjmHl7X*4`12OVzjaQn!CG0Bqk<~Bvsed4G&S>MyXHLWul*X?rW*=%fMgGfn<>32wE?{L8Fd=S61WIC3~H{=sbg9Zz> zHeYM1ycT9K`E%>KG~mE>GhcckRh)4U0-wFfVVlvlQ2-{4EtgIw(WgS#>{nR)Ry>E} zj>1F*t99h|*47t14)W!aBsx8x{L=E-%GF<6B~VCAE8a>Gt7x4HYD!R`9C@epCwC;I z#;S38jEI>^-_8UoRkmgh0pzqsE7XWyAlPCVB{efQzP_=R*eo3lDvprD|q*O{H2Ex;@CC#$`h zf`#RT2EDxCIypOkbbQ81W-^@xl0(%>O(}Fyz8p#$^pNgvHX|hdlpsRrWcC~%ZJ@y> zhC@Y#v4;Af;bekX#?lg0R9dvhZM#xVqs2Lvc6&FBEm3W|gaY$+-0W=>q!VNt4H{;< zvZbX=xJ9U%w=hGrh;52dHO?zVoIDyh#pgt~o>in#sf5g%@3W&jy_ZRJy3B0U(y@;_ zT0MOqnF$IQfg_BWQC!aVtdk>fWJKonconGn^Ix@p{P^+yp2oUEdQg<_;f`o;7OoVIJI~EBPKN!{1FKY8ylyd^cWC43Xd) zB%Xw&rNzaTj$5!OF_0h>e~<#%t?`cozM|dp_F$oWs7!-Nty0%>>kP$W1_f-#o zdy}WCFFVJ1+@m$#WGpobbnqlxZ~)SZB8}C!Er>sM5r+GBm_tfWzebBQiSZYHvS%36*JJ5S=3x1VU~#2zE6i7eJm%qM2%_ zPO-b(*<>;;+V@drK=zsX5>8c`;GVz?t|YMT7*K|?YZAVo4x;bDE*Eic&L@hSPo$zW z@bjnoQA%F221f3^DfU?5Ud+o=addx)Sxn)I5CxBhh6W^-kJVWi1L!Kcx)Z~+Knr{n zyrR+xukXs6DRKfn9|IGcS+5jep7xTXV@D27@B+vyLhZb&ewxmYs*`fR++xJ3c~;e_ zQ76n0D#du(Z==zsXQrqq+_m)7sRu_!+Fh;}eq<8@_8?(F2}2rZY-W}mr}Mg;Q)x?x z3=Inh_YndJkb!`a+rDe-`SBJBcZWu!!((Qlc6T&5oqhIa&s$LD4tIHZxwEsg`2{ib z+cy$N@E}IU_b&UB_Ys`yC(E8dz z@B&d06AO!V6*n1I-_LKv8M>>2o}OGW;v_svM{zi}i(U|PB`nN|7Zh;{`Q z4hOTIu)JrF#3<#BElmxLjPCMqER2lIEKR2rfo!wMhsR~QXniEf-Vt|cZQgXRP*Fnz z+i5L3YgLiQyZxu-_0{#$Qz9fjyZFc5mw6!l$HKy)ASdr190cPR5ebe(6_b)ul9m1P zqljS3%*MvX+PXYHf4bSx6de3HeoNUxAJ{+BGc)Mu=s+k7>aUq(1kqb8d&EaaM@GVf z>TIl)6?6qYetP5A@XflQu)JKOc5+}qK}>A1tE+2b;sY6(Sc`tCY6XYydRN#xvBf%b zAl#@myNcY^bU_!#-wmIU+dSpu5*;dHI%;lLG(^ATHOVlf}m9)2@3` zYZ>5Zh{R=6RXCqQie`7@wCD-VY4^D1_CAo_tOs7RTCX(O96wyuIbr{KESd@u5{%u1 zi*j;4fR;Kk;$veUPEYW8JlGHS_g#+{3T#(-f9FR{HQ@&>&ZUdV$xf#$&$gWf`ugj! zy!Utr59QcwsI~xZ0w6zT78WHXC8;SXEp2ee+q=6H`|wIiQ-G6v^L;3ZZe(PHf#MG$ zI8Jw9=x!Sq8=H=YU*+rL0HpiC24u56H?2(zMF4X75v?aBrIQA6)srPoe{s3adqxAE z=cN|gj3k%v%0YzZB=gk>{W&e=bJZJ_AJ3DX=1X-oh>)gM=yX_=)eftZ86J%I+9h2s zS!hK*k782NZ9aT7%_&(yJ(hYp9Ggr-3e_L9xNrv!Ukfmh095j<`s)O5o|kB}#RY>e zXpPAOEo20d7b8IJVvL%QaJ;y7^JKYIGzyOfKWf8{z18(}aCjL0u0Q{rfR>kcw70iF zDCDM|g@w!IXn4dsH6w$`s|Om0?vjU+PM2GeK_4R$4t*{nFpnn__YJ!gEWrlcH9rTe~a2P7bIB+X{)df3>}MS57ctQBO?23ycsm004} zNA9EjD7*wiQb5?|Mnuw7A2V?KvIamkKpy5Z0nY*(CHm;N6M0q@I#;O&?w6Rj(;t)f z1{|D$`a+~?^$!Rx5B?iUxPM+QFJ(#~=h?YH2iYBOq9P+V(Gb#qV6XvV?Gd>LB}K&_ zHc#untQyAlQ1ZM*WB3*2KCv+|8Q0DOkOF{h@(vtzV(91hoM@|(3=djWr5l_0NXc+D zwn6MXBZH`M%JeaB2$)p4J;0Wl6N;2BMIksTII1(3D$8k^sBRUp}nV0C+Wg2^ zPddK-3d_c($C`M+7Y3Vmb2Q&QJd6f<&*$Ut>|q5sp;P;S+{&|oYGu^TzUVY2R}9cw zD}<-SkWNkxjtAR?qQb((t%awjH>(hEY3^@6N!=e*EC0!xpmR?1<@D5eblO8h`yuZ| z+uGYN;4b)5c<}oapF54qDlD|rM7|o;L~hi^)+Cmv7iIN|9r7!;R1Yp#Ry^7W~W5zd1Q-uN8&D}&Y^$x-u^_e^i^6)69%EIp(Eb)X!R|GnOYnD_Tg{=|BH zKvCt#F@3Q=V2?5li1Yg~;3IzGMu(3Iw z(!l>>v6m!@926OecLV-+u?&a-ARt)d271*09%L^`2>ET?fU^9jsePv-grL+AaKs5Z z023J+qB7w7r!4oX&z|@nMXYBxV5q-F{yk~F_oBsr)scQTjz{;oonOG*y!ki9Lp5{< ze`vekzlnUZH;28_&qMyB1rX{tv%gn`0O|jxmVc1~AmINmx<;SRXpxeT2nU5EB_xIQ z8bG)QD*$KQ=qTShS)1eTXoEG_byngJELN+#@BZMO?F#^$4+(+)6@YRZvzSb2Gv)rR zQN55gxS*&cUbizs(j5m`7g-tWsLw5;T2l?)k5?@8%<;5LMxli{{yD(~5TN{T`LnY# z?|HrQMHZX@*ZPj^i>0|mN=h0~8Wj@C({1y-Ud`UA?HdCtF($vC; z6&Z9FwAg4HjmL7Km8)#LZOr3wg>|$82^!1ddl%75GbzrRMpH?+3WSCrdBqJh4D|K; ze=Op|)eLW_VsBsH>&v4VA7AJBCfT^zsgkNp3S)gk!_BI%>-E7r6VU|;dg_N>g8;$~ zv9!UVAc} zd$eLZCHWl?J?7--%gV||4Gjzq0w{W=PV0?Od;3XYL1}4eM6bcVxGG$1LIQf2FpUas zlqk@cr#m-uu0>fxN=AlBfQg9#{C5wwQY(xa)Z8imHrS4ZNAj820x6TROd9;$!NEal zDyqin>X?|AoWv&JX>Nvsz*ldw1Col|sr}VF86d9_%&97{$eY?TsPRc8$XYc}pltkM z(<{=uOP5|*T&$p^0=I+_O7`xbO*gJq8m*X+kf77*;XGI2`;lsAG{tdy5M4+p5P(R4 zUbf2%$(k#jzOUJV0w6-vu#PEpN(hKxj7UWZw#SJbpex$3j9C2?``-WnB)IG*H?{(#C90w) z!GQ#Gw|EewEzhqE;AqYLx$n0}1_7$qV|P&S?2>+Wkw zDh_CT{|ZPShqF5U7S61cmBsfS$-rGsmgcRi7J-u^mRe2pv#8}HM>|x45_z z-0v!qFGjq_h`!h0{7hd+=kcnVzH#mH9#rLgh{2-50_qYt3$HdFY?Wo6Z9VZ?HjtU*a`e%f2~YRAPy~IIpg+hXDP-7jx;v2zVXb z&Nn`4Xry#Tzb`K-Sy-N*|M5dWS2vRonm0>NT^Z{=O2^e{!D0ghA{IReWX7CIvZJD= zc1y^!Tvsm9>T(Q;@V+d!ELQI;pU)DzoZMJKsVWd-kdsq-c)G`tP@sl=lglCvQ#_up zZ512K{8=6awkF&kc0@KGLq=@QoH~b02UG!z&5q%#Q6;P z2uVpvw~K!aYR{K~CaS72&)6hvq0an%P)-zeC~;zvAhg5dK*u{ioDlkbG?|8lg~e=% zN@<0T&mn;ENF~vh&Xo3*cftK{i464msII3Ra8_2mP=JjtM!vV9SbWZ zCud7dY^(m}Pt!36Pj;@c6fQPu>U7z{OSiTVBmy8GbKV>G7uCYXz*%UtUA=2^b4Ebm z%{pcBsB?NO`}2_4N=Zs(Zu6FwmtWi6{%Ui-{4NlPa$+QSezWIBDVHW=-(Q%4T#63h{)%17X&K?(dja_t|V$jPx6v|M8K} zA$z!fwM_Rk^En53%7}-`>dZem=+W~7JuQ? zM+HhTvA#tKR~$6pqRiFBMR-_P3a7KCiHV7h4nB+4_|ovv{!}4Q3WR}yVP$2V4xyo> z^td_FXtY@-A|?U~6F5%6^5jNTHGK2?EG)opt;4!K5dBO1J-+Uh%(_Oh&VtVmMR5Sq z5*~hW@3>1s1s$*lMEz?w$G0bi89c5p<39oep%Atlnq%KgDo{cOqTry`*ys7Cv64i_Jfy zVXDmy4@=+C)6MkNvD!R8%{y{t0MJNAYN{k~1D$DXU=-4CdK%4Q_5-L#w{s=WRE{%4 zw%|uD1}soc&p-qLmkuuUS?2bcarD0TO=14Cd-$R=xWZg!of?RidxAPVSU$MdWT?Ya zsf|ZQ?bk3mVbs2ub_IP7{PqR!L!H*d$I8d+g5_qRs{;uog>Pxe<7ySz-jv*zHq_`K zt9hKW$Gr*GhK7ZO1q9f7+trt!BU(y@(%2jO_UDSsfj|6^Y71MNn+N*)Bk@?mJzAXx zP~XKh0HqFaKPaU4PA5wmjL5~QeVdoR%i+Mpj~D9U5D*|htZCM^w!W_~&*oF##4;nL zrNyBU;0hJ<_h>vPFyFn_WVFp;I~;(2MMQdoBa)`4 zr$PLxs)-~Lu_U;{#dlsW8C}X)nW!nkh=opC@Kq2)T zZ&y*wH31DP>ns2`fqD$8N%mX!bkqQ>s>AJmV=#d#HUuWtz}`ooBRIIAw${;GKW1-# zDqWT}gE#qbDGq;i>p?O!58Ln9&(0MNuayEjMpm241C<{O&WEz;&iA-3L{@_rM zPy|U&z+I%=Z{I`}32A7KulALST~BqtG;eHdw3tl@N~EPwV?_7GWuB{PX+3J07K*@m z?~Y__?tP;3!bB&54)8eM+X4!^!ZdX?HKW6WZ~e-KrKt`dmej+-!hqWcAxLpGN&}l< zYmKWui`9mGqxg&6U4q3~YnwlS!U#||mDFUh+>z$Y;PrV5vgP)ioSambhP>`pRi?LI zbXk}#o<6JAn~uO7(~uSg9%JKeMn1kYS$viF1o*4#Z?Y+h3JMcjq$CobKL<6nT+L3) zt8G-#&`^JqOS^fPp#sq}nAcj)jV4+w#t?^GUpx6)y{2Mb*3pfXlZNX5}kT4}r74a%y8gYsNi?|yS|9R$PD9Wux4 z^UUzzv4qdXYZ}U6gMru`Q_pTl z@$n_Fv9Y#=G&N(R3ErJf5_&6}Q{34S@q~O$Ox*faoxh9BNc!2#=iTS4w297F8)NHs zZ_l+<7Rx(l=T7JS4VIoE&pmmIY~;WnzuG;cKt&r4z-5*EK1H1tHw7J?XT>XHP*qJGkSZavSVp4I*F3>kG)V`s^&`t2MIoAH*e-kJ^6Y%~5Z=HBwJ z%BWi#U34RoN-N#cozmSMf|NAUjUXZ29Rkwb-Q5k+Ee+BQXS$!g-?QKI7aV?kq1Ia5 zYtAvpxa!WxkQK>lD{5bpQ+gZ42I9CJ%+OHpkwRL$uMQ4oi@|6aNz9MwbWMoE zF~#}8?qK;&S~hGq>fMxEAvrcSz%S#%l%OH{I0~4#MTLb(=mL3VdGfR3F~5XJ3jk+R zP!b#&NxQ^}Km+KQ~4i2>}OsJOU(vv+#;M$OEZnYvP> zc$AUOPQ$p!@guEjO4&EImX^h~pC%py6B+d^ET^t_TR4o3jX@2LZYeD+OcwOf-!gi* zIpuY}G@n^;c)GvJNdWYKSJ(F+?VIzR{s^y-&o2b)uICsgo1B~;u7|_ls=So`U9xqw zwGH&=2kON|EUImYkXC@AnimPV(TA*$LHEPe27-OlglFGi{1n6sIc%e!4kCYMixd=i ztH%||;5C&IGtkRc7!N*>+ibiRVQ0^FvF+pJ*t%F)nm#_>Un~D8ATAMPY9a>XE1&*p z;*LV^grKy61H*D(8F94|&InFWu?8l4K9Q5~wPKaP+xvT|K$rc;N;S^dAI#z4z3L1i z%OD4W#iz$d)A1CF){49)@3M)D-Eq8^D-BmsS948sO;^qB!GXQKJ-|BvDvd%WA>!=? zf|{)QwHHRwAa#6_XC{_3I9Po0o1T|1i-+ zdnRU3tJ?)7Aoh!OKKQg{ltrWz4Xq2qWD5FBZtrfY+sDVHydkc2n>Kx=pdb}o#2hVo zIiD>S7zi&-=zL?N2cU5BVpqOZu;4EpomAE05MJRYr9P-u6*V=vgMtazmW2>x)4V*Y z2ohV@`yajCL9ZT4tFa>IdunPyw^mbKXG%w#lvJbD>K;$N+GM{4Z%IeDAEQF4aD9#s z4FP|4I#-JVvHKlDPx*O2h63$ixjAty3q}t!JG)|`r%Q|_UmiImREk#e*L^hUYhmA7 z_R_ys4>Di+(c1#taDw#qKaDqg-gzBu#bULk5rQ6aPMS$;PiHC1E8Y=C^u6XEjrlrj z9G%Lkf#Aa}6*7L-Xp6{tJhJzX{!*H+H=q?1D4pI)ac;R*bj$_j_Y}@d{<%I@@jA^w zgv7*%(wmwXZai2-BA~F%Q1~+&t~Bu=cw`hIYZyfGx+$Y2F7?!y?JxKc@v16eoskR9 z2ubZHBqgc#_97)R7P7K!7ZrZhbHA9H5lF?(|esrr97uQ8^o?4B-!@ci(?Rl@t zXQdgQK&c@YztnDUwbGnTd8i~UvS?l;Z zQjC`T2cX&$loG>%{H(cr!i@PorL3q12hnVZ0>fK+^E4PsIBhArx_UV{GQAVlG#+tU z1V-2l9~-QfphdbxA~U(X*E^oa7OAX3=` zz<6T+Q<~hJ$TT@q;C%_Bg;^G2WEbwBStUS$H^YT92N90VBvp)aw*M->ajuC0#C9)UK%cig; zYggH>trGD%>qa!n=6-+iHa0QwXm04~)6ZW8ZMs&H&i8S_}9ucG$$%#fa5 zs^1F>qsgrGH6<-IC21)p*9TX{S>1tptg-anQP?+f>c!bbQz>vyXGu-q)D!yfji;V!gAo@jN?{F_)Kif*#jLlrM6X_GfhJB^4DlR*J`u zZWmM2Qvml+Q#9wgvDgX08BSzKZU5vwmA=i8S`So`c0+N4nS2%irk02xg!7#b5X&{$ z8P+HJTK^s<}Tq(*nBUKZDw zna5>BK0TeI&9gYwQn{71v2hyv7)AbF0?|vH73$M5kCvq+KE!He=oJbUAs=;Obe8LO zuxGO~+Yb0~5Sp2QC-vA6W;-uu4-HLrRyV$PAJ~H`c{7h!c!!-Zy$-uOdJiA8zj(Pn z6NSSEln?fXYPp=!5c1HwoTS;UEH?e&Y;@A8tO=8Q;^JlH3;K|nVu2b_?iWD;leTBu=teddP8GDfSab5z0|?F}Iv#q-+}hGE6?*hX8}w6p`2K5Xf{ zN1+k7eP~Yj_@cTkUA;2rYHG%r0<(O?Qbd+6^#@6BHrii?C{~mRIWxEoMMk5^uJT2c zUN5u~DA0r9Gp3!G&elleaKWF7yA5+;!AK$UAimf*5dDF4`(R@~IpY}u5fKrQ86E=1 z=<{H)6gg%%A+s(iHX%Q5i)XL&{^DZ-1Ft*%8_4-NiuCZ%Kd*%G-S_XXuUzVZSUGpK zwb^NSj=fkzhGl`h`ahgg)(b5HLQMC!P`uT(HVSeKUB=5JDd>(^Dp6(3v)jsoU#gCW zb(-!*pN5SjyJP`wwtuoA+`3X_a*?#=lgVpqBPS=PI+MINoJc`{jf-11Wg`~wh5Rfh z6pxYquU=Hfv+tl~25{^&nLU3?!$LfnQp4(uk-Z3!&P?D;q@pFqCAF;wm zm00nd-u_-9e%$41GX=<}!GRVUMsF7a*)|5ZrJs`klA8K4E+cdB+3OWCUlNCt1%Nr2 z^h4&RrdVvOMQc^6va$4yyy zcsRK<#og_cQqjzes!M(6%X70%3?c1%KF{tzG*DRs1I1tbv1AMs1dqM(uNHx2sJA!3 zz4gn*AAbb@lePA>d<7C92FDNY16>K(Fyzzq5qHeI%**w~rClOnAv_-+pXpc{90Y(e z4~OOL2~0ZT3~RLxRQ`i}j(ZjVq&ENnTAym32R&6-m^O@%&B=6nv$NED7w|+tvhz<^ zH(}Tf=~!OQ2AoCbhwP&xDn>@eRsGd!(@~o@xkKX!5TM!~bqr`sP9{H_tLqSwK?(f_ z41vjjk?{neEjQCsK=T9jX*{D9v@RPPhCYwX*I-zvtgHm!+pj#Lg~dgVg6TD%&#tbT zB1T4A%=Q3p13eK;y={Jm4HyFpOR_h?Ys?l4P08#5c#g5L36zkqaMmC?6D{`PKIdyc zo7s?0l|L(jVIgpE*?P^C@9BDbx7)lPJ#KoSmW-i(Hl-|AWVCzxk5p{PLXtQmozDF1 zmN>i>qn3Bm^vJ|N;N8#ZH&GKF^+9|II7Q2mdve5CpnQ>)pgM9SveAk5^_r)kIyx|# zTx_$|uCFiWO22GuKnn`Kg&@Adv#`WkY32-5HZcu}j|+aMFUv!OirU#1_?)aEyncOe z!^V;w{1&Pl25~-C99MA8&}id;+m*RJwc6|+Xxi41!}`o zjvD)|B*-UA84{kj95E zKd~U;qhaG96|KJ}%1JySDvhj2!Vd@jzlcnaIbT!oAgNqRSHcv&C8`iO3YUui((`nGVIJg*8 z8Ph;O517vGaSaZse1*)($N#3xp9!W;u#~5>JBC8G?bdAt&-~!naAmB_6iiKCSDQ`u zMNk030^)j&dh6NX7CSKm%|qnqDEWEobgJT>K!%m|VtdO%m$G?SuyQ_2y{DeV7=OoUyG%IIzILEi3^*PjI%)BK z(w5N3|DM8(G?A~cw!Z#Zr_M@%n2^uxy2O5~-+HyGcX6?)D5N;6PznQ9n2h{$Awbd& zEDp)T-}M@AQL8;TAA|1!sqb8w&OAUWfW&!FT}4GhMa7aZ7c=w_Yp|V`+RlZb#aibl zxoCV+63~{%3BEjDJhwhjk&%&gbpt%3M8EdnY^&tdu_&5+(1<;nRuia$wX_;rN{*^+ zQm?M0V(6KeK+?>(ek*x-c$oG-Q9ln>nL+!Xsigq0#C^lV(S$K*25Dkkvm?jN2LoMB zR{_}x$PG^e@JCY~uaN_YPl$m6jKg+8gbklklHqk|D$1Jk@toXw3i4~v#y|-jT^)*x zmoluO#l$2ga&mI!FD55NeKIv&HCFsFXe1^s3SK2Gz-4GtymFE)t>xu>zhRn13PsM8h8m4)qeORV0U2h_uk*z2Dvb@iJ2U<#KPW zy&yAp7zNgkfYSyw?*182d&3@gRiNZ1tKAyB{V#ClGLzW|wp3^SM)2)hSjb57$9o2> za$PCQwFm(RO6zSz7CVn8wEQ1PgG$gOBq_xxr5Xw;jc;4cUTLeT4Re+}&-FSCL@AL; z(Cb%;vivACX!j0heFp1Mx82zq)MiMB5pzJ-y$+EZTmZ0)fRTADiP=A8rp|pdMV^Q^ zj6kr=T1Q2n{d6$4eV$QgaUgs^q?>H^jYfw5=(O9p zZFJb9FsqDGYk2EyF)2yWtRc^I`bT$B!!mHYAj0}Rw(t!`@qEdppa|!cP*Vfq*=>Tz zLUlTAVQ(ynUjPIvgwji=6rt7D?#VTWgZ!>e_1TY6jFD>HWf_ygo zNCh)tfzM@T{-tF1QZ8M4sTwaG-(iN_2~C7YR1yvY>ZgHEV7rgrU*^sUL%@V7X(oM| zG+;6KIvS!yuc$%>S>IRrceeaM%8tQ|5%)i^_|A4)cP0$yB_JcavW99-^}(3~HTpDit6?kn50ply6yL;bIT^?ufAPv{(u z|e%r-dr{z&YOX`5~g?cnPXwrTZf(#&?PDhvtWp=cfPzQ&U%!w1K!^ z9yJ=>u?)%sGXjVf@VGfagBWII;W6qb@q3INEmVVnwqSt}_)7^mY^;6*$^g&|$~PP_ zNhtpoIS#p$a!jZ+IX3ez;wdu|6JTg216#-*jI0t868k!ogSsMsf>cXQ^Xv!gzQ6TVNsz0G)E8PWhi{tl=oAYQFlm}4RbE~R*R#B05tPV}p!c1#+le?E)QA>+R zlWz>>Zh3F${$(DO+uOIbbJO#JF4tC|zizKdR8>`#l^e|-6PIEF3iO}QIwUBxK=6^q z>$5S{8wz|9b@lc0-gwBK2Bk%`khp{dX@S>LF}b-E;G)PmYg5#pvl1*fvhq`kH+)s= z?&zPT6TwGIDd2(NvEk1xpFOZj4s;RMRh`c`DH#Mz^jfQ@!HNp9l zo15M1=j=={4xI@uQ#7}oFTjNvggbhEl=M-3%2oAt%v54m$PBqTPOPrX8Xgi6g7~}$ zJW~k*gxD_*TS>dgh}A^kdiHlMo!1U+@((P_=f`SwiJ&BY3DZ8*xm}n2T>-{;DU04_t(stNhV9AF+TWmwPjRd|-Zd z_9oT%QA2AVq5bn}%e$qTEGXZi0|X^1i0ehduLK{fQb!&i1cduJhN|mpwPN+TQvHj4 z4E+lYl?wyW%!^(S5Fm>IDAH64hin?4xgeMFw!(Q1g8wh3`sqMZ_FluP)-Xp?21Pug z=tZzS43&!~M!VD(AL#@WE7W$JTbQNyW9 zDNuURx*%O?-@o$$(~{36b&hZV*rJYK59rJCU{kk_G9kKn9 z?b%SE^HGqKo1C8JWMhkYtjM*E$OR+Q5Ahs5FBf%4M>xR`v`xhra&@^BF8fW;8d7MD z3mDAXtbBFIWZ6UAcUI2f0DU71D{C&v9hH~P;re=o@%|Kt^&+r0)ScO}A|N232RWQ| z!41ZM!J9Er#zaR&UETX4PqPZpFfJ;m(j@4RD1rA=dGaQ?zk=lnU)C$lD6yiW14o4Gxu&tXWbEVKzK?KH20s{8o- zAnfjJC>v%sraNcbK3Rw=r$Fq(qiqnybgidIfI(WEE9TjoJigoANh&NE?5V}gyA{3Z z2Ujv4nR&D_tRRUon$`>Ld+7*Jv%7J@Dlo_#EK%qjRC>|lN@kL37=8DLXAO+}^p#7L zm83eE%*ICkaN?W%ljKl;fBTDt`%{Rl3>G&JnCyDvqvI(OytjcWQN#;Id#R4i;&^A6 zWMrttPXG7P&Qqer>8c}-4JAMYedzp@jiQqwF zj_Q!6ukF)oZ~mJjAz4QeH8`6t#A@+Gdbq(VH~9F4=2spj7UhJrOvh5{V-rv6myL&J z6^>M|Gvyk;77;LXC*0iuJ}bYVAb5BOm|IVl8@pG>7;m&r?ojY37XCqd!^6YHpr#cS z>9@P*W*5=Y(eZje-U7q6@&%db`^gvz885t%#Kc5FU>W*;wl!D&LdD9e!ee$fjK{=b zyZR5|_a07=;XJ(k5ryz-ieY4Q9{dUi2k_8DErQd5B}o?YSEugi<#+RN19cN4WZleT zFjpsvB}!2U7Ul@{2vYG(51%>(#oNMhqYrt9yVB7{>MhLKw@p<0b^#pGK9D~)g0R|Ev z@){8~H#avRAOKLoRfJlT>I2E(3s~VeYz;o*;om)60M<~EH8KJilwVxztoapKsyea%%#u{0R&wAV%nNZ!+Lww^hBo%;6t&x={I90s-M+IFYdxoUp)G z3^;BI66J+_TLTA&i%x%d1UNV`QBhs+3|w5AkFAb8E4w{E{=L4nwo+*UN$0x|92DFU z!L8HGC=3#8Y!Ia*YPS0$FpvTA6IkHEMdful0a87HfDI=wj*dLiy4w~fFchjD03)+N z-mhQeR2sAka|s-_#17}%F^vw=wyWEMZtuCA!{!@Rl(gO+{no#s7Q~|b5Ef>B@h2fh zs#q*klhrhim~S9F47|HVB<4xDKW8o1U-`o-qJ`u$-*LBd_28iVE6+0^hwA% zPj00lT1Qdn@10#=CZ1#MCI%`hFlIqB;thytl+8S8IXKYO*5xhmPN1M5eSMFA-Vw5C z^qxZORzTeDlvt2W6@!SOWalD1g*Ge#x6Pe`%O`@ujBf^Gytuzk z{VhaMQHWkoIVD3r*vqtVa?-E(+vB#ep(&-|Cm}Q+hJg`s$oc|nX9B}=%2XU0&z8Nt zNTi8LbhMvDBrKUYwxy*C0-9-*@b7X#uiJ{4W|zCOv#yUp4XK>*rFz}(b>ha7yXhI< zrKL%Cb;F&mBJGaRcs(Ya?rSxU&OkhxLQ$p{8f(Tn>nvW_*niCGTRuOW4t#wT3Ets< zTpl&`-g+mD6C*y4qhu&zoRbaZFoIR12Eiq8YkhVW z(wEYl@E+GE>A3t)U{SKJm;qTg*eRCg=Gsmbi%r%#xhJoj^_t}~*qB*aV@0y4>F8{K zSAdCj#Vmq|Ra;E!hwLkmyYiNRAm8iG$i&3yX0q5mmv_9h{x+5l_g6&OA-92M6)eT93%vMArkQXzASok!XJoEwAp9` z7a*dfti0If6=Z%0o?xNgrTkl)zP)`Vb-?ZspHfR}d`-<|$j~dpf&k&h zSBKPtBfhLLcM^6B$JjJM2?UR?H*D;+_t(c@_i;}u8gvJ~@&R(y zoR;tGGtv0dLm!HaRe# zSAOm*yS;{pe(c5t`8GO01Y8tP7atd&o}QlQ=E~=OS-@wOpH=yJcZ>lbTML`5=4YEY zbbNHyj$4#O^>CfcV*h!eImc~vjG_c=vxY+$F|7%=W zsB(R}#i&baTuEK3tt2+?SWHDkR8-6UG)f?RDlqGK6C33lPd6;!O%4m)H4yyglu7EE zzof`SrYQP#X+dzo{)X52d7K=9+c5tSGRhCr(E`@xWR-lDK{6g6eWU9VWrk5`EiLKt zRmZhh%p8e$suYD=>4tG?H4VC`9Yw;U{ro42dqFXUnQuI3GfaMi(h<@ zim%0HPxSrAZhK{7B+wl^uBmYmJLV$(>{7jRH~(2)-V?H8vXw6V^(*4Xj|XjDm^tma zG$zK>E+>)<`nrob9+g56b@iIm`d^oqMcj|0t0f*cx6^s?_Yd=DnuFQ`a&p;V7!;l_ zE7#kq1p~x4p^1{@xNj8-WYpS zrijal?48clE7%Vw%%nH}*2FO}`DCd7yPnDG3^SpMMU2#U+j!%zAw_9;0hNOEr>Fn+ z4Jq=c8+m4fplgg3t_Fm4BaeqG3##Fpp6b7=ri&a$> zCzsmb@P`EloBHkBo}J+q)@MEg1Edb!{|xUUi0rU9#gKQZ3OV8ljY9}%M-{z>(Ag%u&{OM%-FEgY66~vge7CIJf`qDS*z(vT1AVu>9l7oAB8IxUgz?hsY-~OE!ah|%m~*hdKU?Jo@=)e5|FdoopJrn!``6aU z*xA+i#NZ&t-n8uOICKdAN0#i<(EdRtJ{1KU`R|=z?%!~N&fXn7Ynkn7s!#Lfy{Xh< zIImwD0uyv`@x$|zN7&c@5p^Rz-K9{hmCFofP?>k9|{y?c9#=f28L3>ji@A9I~N4x z9or>!e`7N4O z2$R0h#h(f%y4$;WJRM!#Eqx@|54ObAG{UJIQAwK2?Tgl8qC!h`q;x1UiO{KNnF8_S zF3y3yp;<1r6I0N7dKk#yU)x(H+TIaX!CGGaS%VE(A9!6+2_+$6zJv>p=C`~Y6610N z5fhu34aiqGERzz*WD*eie;4rt$%zIz3$Ay00=q^jG#%Z*kY5AW-fF9-sV83=FT(wO zR2;076wU?I`iY+TJi37Q_nXr!EmjD`$0s4P09#5b3MoTBsb z?t`Ez2`rqjBb#m;Y9gcBNO=>3pRZERr!LrmA!t9}s;bijVnK8Q&aQj~ z3`}TjkBVV(uxK2 z5opUTfzR5L?0*)?-b-)!fU=v1hm7~u@v+|0s^Z2p)$QY3W8TG=E>QPC%J#E(I9b$=diqGF&~&7l3EF8-+L z^O$2KrNTho;6n#Mmuh$JO_DO85u%6T!)yq9!5-$Iivh&aeSBwUE2}oh2^HuiRk+kOx#(5Ad(IEXKhB0lfhHcE33Ze;4s= zBa-!jl@9o;@*A#S!QUf*4h=W9@F!{cuL9E5owxq5hOR#;!~Tn3cv8JIsLSP1Q3df^ z=n|385U@xG85x0hMVat5 zid)}9 z+#QF>d<6%GqxZLHwg|`EvU|0a6r*D--3c39;8N{xzsRw1ItOEMF4kJU9nNRfU|*lW zAq0tMyapMfJ71aGCJmVA=p^*i|VsWH&eA^5{mp}tDM-`p135#p*J16GIh~96^z+&WHz6I#t8$92Z>06KBp02gqnwd$o>DQPmf#P0X&IBg-%*ct%{hi@N z_u2?nDkl7jV-F^MiKI96oyek_tD<5$b*$b0*BeYsBF%yhztBU>M}1o3x`*+5ft_CL zYD@GuKTkeAB_RP=u0R@I02SQ-soXvSJnF*J?hm+u=x~q^EPiAX-urpr_yPkz$i6F7 zEoy6f1{>kO4z@I)XUxF6oPYmdOsYyl`#};T5aC{m*&poKtW2aOWp-u_4M~NdOUDKe zyvr+sM`yf5apxNt2#X*NF0S)SDhP6=jkhp258j1)TB&~(p8Ed%`#f3u{ZA%?rp&+T z#S!R+eSPV;kcSOE7)GsMT6X0e#HFV4nuLU=6n!1W4SfCjwdRtK<$o5)ZfBjU53p!{ z9FfZMVUe2s;MUlvlGgwW4;wrVJRfhEJ%2Aa>=F?YRcgPyDZVjprGrkqK_|3t$qwTllB%1%(>Y<{v zT$CP8RNm2wXYvc9;;qUh>sqEdmNfR z8XL?v?*7S4+nXk*aAB`;INV(H&}g?vJZY(3YCS|n4w$OxegEA%(8;o;=jCXscW!R1 zEVT4EW2Lza9)9R6R~cihLdGRUB0RhaCw~ONga1ecy5>k=<%>rGCCvxy9>pT*&4i^x{o0S9@%~xPF42!yy|jtSFe_r5(iE6zi}I!!`7DMjAgm;4Cw4t zSZKeay+6KA_s|arLV*==a$-+KS9dULb}%*pOu%NV_3l;$ui)o#JSH6_Roxb|e)UIn zUyS>!`)dYSS?r#^uRUhOf@69WKe<>{PUcS)grV%$g%*-omwQAFwceVWD_U9YUHqwB zs>@|R4opiAMMeP`Z-wx!!^?a=e`K$FevI(U^SQ+wZmX&uXS(pjH%~+(d=5q;V za=EJ>^wVV_RjHP0*IIzHt zFI>*ZUSydDlAKJ(T7P?-$D9)&l#>{9U_kmvG6(R^RW6jPL#vME;7c5IKV~UfFx3?S zAs3TD+auW9Kxhy7nVahbjMH=^6?G4Fm1zrNG4LYp#`=UVB!>3u7X zW)M6KSPxQmPP~?iQ7VL)0@u(pB`=LkTy)8@Ds*aan;d6?`xXJSUnI z(!wDn+y7my!hyVhPX@fZ_;}=;wkwpeqCkaE$?{(x+dDfxS_udJ>DPkIgC3!Z)Z~Kn z5Dbz`@F|2*VKw+3!sW3DH~_YTcJ>S*W6|%$#WK0cC#+_(cYT8I7GJ0r78YXj8z{&r zV|+j?LC-4^OhHd`LvyZ)$&FXb8?)cIztrXxK-Oh4I_N&^hBRx}$_RSh^Er2IqpK&Z z63k1&A$`n$@X@f~*O>~mZAX!#q; zX07U$1}-g*=6enbfcHH=p}6wdxtM8bPC$`TirejDDo{VFSd7dil$9Hl+k(I&7GlAK zcLoaS+RNE+*3XaUYmzCP`OlxkW(Q~D)OD9d>oMhY{DCqVBto|Tu|i{U_@zia#6pYW zK1R0N^Q#}f=#A%?9RK~^?-HAn+?J{BBkYejFpvR;!BDBl9h5sKTmsye^|NdQRpi?6 z#QoS;3Z+iGGi}dhyvzuy6o}LI|cB8?~06=|T3p&W_>3PkK>?6D)l9G|(WU*-d8X$4;hiB(X zNdT7(3d9sl?Dh=OX+R5lJ&zc($b~I6G&N}^)x-G4#YI}qSxZVwgFLz%MIjv>kG-hQ z>_7&9R*P=|H6bFYw2aIylWtmOrb5o7>G)R?t_k6YZeR%nrfi>wYdwCKpHmis z!~k1vHKEfQNOssQEnVbflpLt6(W(2u=V;ze7^I@A`bbGjE69}Td79pQ6G41&ce(E% zEDROe3u<^kaWTqUZWx4DAiaR{GX!$$>0M9k1+r6D%w$tHS){13@6*rQ9q)$vBQt>- zAvfUeNb3Z21Mzij3m(O5G(P7}HyK`#j>%5%@cAL_&0jG<3ue1~B!}@GL`U~e^@3X_ zR4*L0v^@U(i}+1Qh`2X+@-r4DCK1sQ9z)vvLLKj2pTjOw2CvU+NErfLaJs$7<56g4 zvzSZ*9KxE_LRGJpk06B0<+*Foi2!Q-tos2xZ1xkk>uvDMzf|L1y<@9H4|#YOfk>8*xf};$nU)gh!>HppJJi7;4HSb=EcLtR@G_6$3KXF zg$IE=D%>6HA4ZpdQKi-5daHqc4Th`Pr}4Dh>HSzr`8Rw6_D_AIE+@+>icy&Y)b|TaOiTfvu9P+%*LHh7jisFFtG%WR$SJ5LlXml#uopeI}qI-pEFQ8m*djC zYRHlyIU8TVs$fYK4+JWM40eFBQWsN)3Q!6Nw4Hu7LK68G8lXfL^y;nda{%qTclM)A zXp@t+>})e2lmM^20s=IwovU65AO-D(ofV|#mFZ|SFI1bdF`D+wCTC+(6j^9H@^gtw zE1!2{q#HYFxtI%D^i+sTkY$ehT;2`}^+VlWLEc^VpI_~0YfU;i6$%I}OKZ_0=k9aj zy`{OmEbRM^6^Q@{iJ@VYk0QUg)6Pd-j-@ks;;(jRen$|+TDX3z#~e!?{wxK)s|?o0M6vmTB=aaF0Is?ePBzX%L&D-){)x zTFa;UqKXf}j>m}!rF2OfSkoz@)NygJN5Jx+$%}*i*d;qm&%s}I9q$n0Am7f#x?-b! zS6?&oDiV{#marv5%q-bF<?$#Qy(fns&4x3Dhm>HTiCl@J8g>b*vS9|++h$3>O zkM&%ZmWKeAI(Ov*GdF;7T0HNj78mi$-S^f;RA+q-rgFbte2odmoAU+o;M9E+ULP5iGuzg(lyjdAaK~EV;d|{u-o``{o4?Keh z2M`QmVkp=aZz6OEU=E*C&T0!PxEL9UA&WZNA$1_1w9Vp5+l|}po6z5b*~9qa7Ft1; zbO`CEog$EOcLKVcU*NtbIg=TDUVm76kN2l{g@BD6q#@Mg1)A>469K8h*5)Rs&3T*S zKJs1Pto~t{UOON%x_+`g^B!Pfel3Pl)4OZIh+jkbxl-~k?YHp)5BR55_ZhOXUbT5Y zAw#gz`sD2Jsi=mbpviw@no?5cNJN-FI&AsSOZG)L98C2l=oaOaZk-5J+Y5{2OhSHE z6i^Frw3XDzN-Iw-4i&kSXKYV_SbL^I15`9rt7b>_s6?N;gW87??jJu$d?6AZ9uUMv zHyLUSXei*%9~&RfS1trQ$=A0QwD1c}{M_8VMXk9-MMJF*S@KwvUiX)cQ*)pVs7Ylf zb7^oYR;TRi8?9lpjEt3@?v>FPc>ShT-Oc%QE`HPq28vKii+71}rP--e^^T2pwC8tH zuQBw$KKMsv<&R3{*iMX6G7Q9_p@wbIVDEBl-XGY=xr-f?D@|Uz?K%C)vg%*H?1!&o zjK}M@Q}PYau)O#4tTekhX>0X(4Qu~*V#7ZOJ$7e-mkTy%xuiPkUerTz0_{f2!JyTB zrE3C&q;HR=79>~it~NR4*Bz8@ks+xft9ecy^SZq7x`Xf!dR|_S;kje*Z-J_XjR>15 znD1V8N&6Dzt>duBnt#kXftq z+BH43Mr+M5HZ@&&g$CnWXPO5rFG|X)(9m(%*b?DyY^JkdFzj}QW*aYX10_VIWu#~Z z=z4m3%C5Ct);kPIf0l&tdi9memv`TJ3PkqnwDgc|9coOfgUb?2slb2i1DM)h4*3y6 z7`tNzNt--+9eCjWh6lnaV%+=LU#G-!BbYW-@e})t9cBSrjpM% zSeWl@NT2P=qu;*tRD_L-)-uUuvyI%u^*lt!iyA0rEzr< z@g*@kQeTX`wb5E|2~Cn6D%Ih0xbWoQmC)d{-5oo{1&*u3C#Ma)^(^hn1M0`KpTYsPs;Y8M8FA#lrX7%x5jExkL80m(%MpIdfTmOU5{CUB|%dbhj=MKHX)>p0>qFALVk5+<@Yo+Zg*Xc!+7HiNrHHg3_+i> z?R{2Ag$20nOL-UBfG(Iou^}z!k-@=X7{~$G$h}=%x{uB#yVs@tk;KkU$*_=(*U={N z&x0`JRQa&5`f$GXpT!aM+gzUxa6sH=mSThL3lzkBwrZz!>0J=|kzS46GZ?`K0$Dlz zK0?-~cmL_{p*kwEKKhj#ptX2LFaBNCaXDG7J~0L(_SWQ<0HkAMV9~j7&%0~Gj*=&G z_u{Vk=BW6x@Afody61b?t=Q@k{A-_Bysd?Dv(rNRr$tfrlGPv@}vd|%*PV&2L{vO2Y zA0R#?KV?`dDdCNt_793Iy7Lpsnsc{RKx=T^5Lfh_{>^MWkP99JuF`($=6hgMksLyC zVVO!^le|`o-M1fwU+e}(?d7-nxx<-gX=LPN1RbvVf>drQ@UMPc>owm%IzT=ZI2-SQ z|Mp`LDztCw1Nw|sYkU~~7J1Pz@R{}CDN);94dq&XOB_^ix3SUxdsV#qs1Tjp0~Sp1)Du~>(uBpARVCi0_hroBmZ60 z?KfHf#hJfersHj1kWWTdp8VA7?utUddknR{SGfh6!g*WR{M>w)4zL=^;Pe*`TBRO~ zT#py8em8LOqg&UX@ zv>1v@mn!a_9)5QtTuX!N-qGeC1eOq|*<6?<6dvKxL$iG5-HBJ2&BdSjGF^zTS985Y zi>v4Ptq&wBKAqpQMn%|gW2`h)o{*Q%bMMort-*|egedG<({Zbz^?GkLf0K*O=EhqB zyzvb?cgI6_?)4(`X{kY)N!v9vOiR>I43Y5bM{Yq%@el57)wTxk0nkuTx@SP?)H9V! zk;*9}DmpNhIj-N*;JVhvqSW-u=LNv$`-X-rcudk%6iC+ADAF>Aw-koAc6phZ*brmW zAsp-hH#aHOQRU>+Y3=&5&mDPAhtf>?+sDgbyH>H?`OvegaeXDfc=k$0cx8Nkj!2as zQfBk|wckQ@Xd;uDpa&(ZC9dUQ4Ca~R(pLXtC|;w0K+MKQ6$}Ckr96E|2xdWnqWhIm znt+}X0Z^*ECxerhU#pb|>?$_)K(xwN773{zEMLj1rN(4xanwB0QD8rKy1tH-NE2XC zt2KIyDs9hfzwW!;>{2C@P?1Pa)94(o@$&Q~h1e%KIawxwK3Tx4>CTpjsv#i6P*=Pq z3PYJ@Ch1a>!@o4==hFp8Ki>Jx%9@XRRi=4FLQ4Xw4yA>d=>pBmd;Hi;jY}Qg7yBqpz>;-D`_@jgGEobv%HH zEHeb@D9AO;W&QyoVj-mTm)`O=W2UF2R|^!Cj+jsouzn%IArbJ93ukLJRW(FkZ?6Ye z+XZ+Ccr&$hTqbYG3 zaN0wYGPXUAxpnw@!}?Z*d_^d}-fq=>O2v-ycK|V;;L&7IE@&8IceZzW*92^i-n_8x zirB1TUd??wP1T1A5NWfT%!W%H#ibw(z84e2f=7~&XQZIurl45pnThL;PUY#{5!T#c z{C0(k3GPxLgEy|%ha0gkR$jqDBqR`^NonsH+&;IP53)@D)PVeb_Ci&17!5x7bZ+ej>b&gMO~SkAMYhs#>c6r$#%K+&_~`kF0b+^audN#!K=Fh3&8a`E-#F#wF6!K+EP8y{&BWjKBn zwW=SowzuX|Bp=5_M~@8;pDs6oH3B}~r^|zbAz5o-Q_a|REfY{pYL(!vB*Die$s}!R z0Io+J&4pkCSfkC&23`H9a;mP=`xo_Va3_8SWcA<0&XiP@YE(#+u}hoU92%4*eB~HTNw{HY({#rWzY7x zw!F5C)e|C}1V#fVZc@7e(ti6Ki7#K&h+8)%qH~1j{m0X<)fA%;d}F4jU=Skd^|{8| zd<-CV$y^(&PZOU7+EO`7C>%8Dab;#W=XSr7g!b|^xZwGBLO`pDXh z-Y75fmCNnl5FfL9?DmY7mKQ@3QBwkf?~!2Pn;r%uK!?RPv+oYQdRNt_PR_>jfD}# z+h*M7rE8$b;Y3NVT8U?3Qg1xmsoSa_Az|GoFB@&ez$nRKoq6y9e6MJ4iAOhcnkUlH zeHpLfONcq_Id7Z>u;nd!C03xJLd%&U6h?HM6*_I$=j-BGcE`rmUnzZ`nJxN|>^t4L zfbs66&pm|JXOU0Vjsm(0%+BF^*cc^y2LYVIs*V#hJQSnsi$WRM_Q7i+C622ryEfOE z2?V*;$Y`nQ`Hp)xdv+)>4XKML=h~ye5u&lR_2Fp{OdAdi?C$OY4uJ4$fXx!jzN!JC zWgAuAS?ZyE{1}2$<=z@oWJPg1{t_}6Zk^TOTmNs>)fbJo#ARh+s??4^nyL7c3GoU_ z`LCzW&L{5Px6geOO_8{G@gS$RR^7t`^L^5cO{__pDdv*2L2q+$xtuJ@UwcXy4MLLk`@*e zm*cxJ8@H;iMsKclX>HWUsB+z6RN5nQeaMI?HbtvBIqU*_MlK@-(|$g<*V*Ly(y|#K z6STv(?$FtIi19)~!qfRMR)Mi80-{0Zmlj*cgH!OX*p_xWBvdt`YmMXP1{=9}=Et2xtNh@da`{arwbUimNe6H+fiYW@oo$PQeM92slc5dS7B*hsn-GPrh zN!~aZnYfL<;yH)PW*w`e{HEh!9HT2^$Cr=?TqIAn)CG}DcLrPqtXFv6O;3#Jwl%wi zT=x4+M@_%dUl@Nb;bkPdziE61{n5U6sCAKQdY1JRyO6e**HNcIYDSF%j!V3xLcZD;FM%k7yXI{k{qf59yW;4vFTY8y zH8VeYQXDUNc!`E*($%;7I`1-^Ub1=l==?f+<+Eqp7s)-MK)!140LBsXLzwC#*%Gdw zpPs!Q|HW@l|JCs!3$ODHuY+d5y&G@bv{U|;CEnIVhN01U|Dw;ucnO!26BViyLe$F( z=hqliCy#eydpqtliCX3@W^S%eEujK36!SM~kE|5a=Q?7ksHh-t=R*e_SQJF8`kiPm zhg;EB)?TMIodSm5g0TP?{q zvfgb@2#ktZz8!fjrFi6(mgmlFr1JAkVzHH#UCxLsV?^T01lh{P0qM8&L)9=X(KWw- zp`oSKlcytk(X|WM*gQNu?2$eLyjvY<uR8o5N*raeXcrinRg3WrmyK~g5 zay5Qwnzus^{oy^kkTJ$YU&g?K!QH3T+e!!qKUmLA6m$w2}(6 zP$cGNW-!kQx(iyjb+pUG z?j0R`#sgi;=(vQjwbBz=ui-U`g%Pnd-x{+Z zA%V2t(H~8h>m%04Z$1?dx)J)-!te|V0_mZqhdFKF2{N*JmX^}?wb-d^` zw1L`&(sqKbVVr-|NQ?2*UWwDMg#gP<8znX?39yqB)Sa%pNj>_wTOBTDrw7i&mtRP>*zPah#F$@N!aVoWco^fUnOWDgXZy57S(%tgznG|` zLh-W!0*VWQ09#?;U%OUnJ^Ja(R*u`(#-oS@w95{3f8&QstPXLDzldJ;Btm>V_r6|x zacPNYehKp_B!QN_%J(M(COsep>>~T>0H3uxLm1B-Pp>`9L|3!>NA(U;!6EVeori4sMCo`k$M$6;800v+&JRC#_36( zFEMsdkU2-xNPBb@(LE6nuLm*v(o%8Yf9JfP-P|OVq2kcwaz2Pmpqc9!X-oFm@3t?o zvJ>tjDvvPELfhJlp7y+ZIcAG8bHV9?DvlNJez-m0L2@e&sc@8zWGE2z2c%uGKiY=5 zM)g8YvWdq$lO`~5xAer14bvL0J6neBu zT}6f4cJQ5?TySEdHuJHzPG1aN5i)6JuHzgumEeJUtk?>h#8F2S@QD&$Jz8l+$vHg^ z*H4gJ`hdZ&oDvR3*3}u$oKqw`cx=ChMuvvQN+wCCl#5fmf6Qzc9TauXd^02!{5^d$1JUptHm`Y^1c)A3STTJpe+buDxA<`Itk^df@&9l94`Pwc$PA|moP_f|*C z`ma+3j{#Jhaba`LWVDDJP|zs5O^^Wh5h6IY-Z(y(u^Lk;-aiZrTS9y!AmDpML$f+o zi2Ssl~y#?OmS!ms`VigL`frUFlRApS-WwP0U!pDo7&oZ{Jd{puY({-WY42m>8J>eI)LNb)k|za)q*Bi#92%} zXXD}fFeazrW;r#}`1pe%F6MBtjb6#9C(ocLSFhqd#3K=t>kB=osAqP z9auyMq(1y{V&>KxJXQyCwzijOMKK=H({kC{o+3NH>(I(ag3f~-r!V*{MeXn~(93mo z@b?xJH5ZD0dV6X3KA=b=F7q#>&6~H0$X13UJV`2m>&-X0XSO;~^F^nH=I7(#^5WhG z586c@c&69(pPq)q-$#))wa7fA_#ot(nTd|rbv~w%ef90B74gmI)N!0v==LXedGjr| z>g*gsv(;4Xr$0}>KZm2J*qov87q+)oEEX14C6`xg z>qbJVh2BpetsS&_SfpqaM{QwY($c)F%bQrXjKR*YoEEyShX-t_@lWoF&|9g=e26EU z@1-99`N{dXte}$DN8*xj!>41(#L1K-vkj4r9ls&bl>4I7+IEkw9INal~thN%dGgnks@0c5OQ7s?tS}1^KGjtXSq8re| zrK-I>BE4BTs$47>k4*-?KN11H~PX`rpC1=*~Y>)Q2h&j|8DZh*c6(Khx9YEA2?;=hx_mdPnN& zUbDK?Z|HzW1rzIITTsJoQOKcwdY4=On~yl#l;qO9etAb&a%N^!aPT&CxVdP{0W>gd zotJ`s|451rBaA%~`cTr-gX9x-ikX(9@3F$l0dP@<3yoW}na7bXr>m+?m#T{M^R1#M z<5N?^LP9|5u?4CQxq*c)Br4I9phQOpI4cm2GUV+~mRl|h`^r$0pGm+cTeVPl-wz_i89DVQIm7Seqo7F+xH*aG6{nzKarjCw2W?L5tc;N2)FCJfu zL}V){$lSzo2Pp}n{v6}c^qb)ZJZ4AwIWatL{9vjs4DBPryM-~l#)jnCR9;wE&Xv5N zbgd7XYA|XSEiuqBEG%;Bvt;1v-;82?fga@2WC zxTV)fIVx3^dk*99&c~a{FWT|E?QiY=aWw2RF%em8He%`aDzLHfzRAdhcx-!KL851} zV*Vh2i=RJl7~OdG^!01Oj<|34V?Ladd-manu1uL_+@%Zabv?9kaYt&%d5rs1!sp&^ zZF#x1Y|hn@q5^Piv;c0^(-Yz3`nQb+r$-*=G<_lCWH%+}N2RZ4j0)e5-fwLhUs(P;@q*l*q4 z@)i^XP$dFk{1pb&zYx#exO+D|AWBU4!Q&SvX>0d=2${I&4ew*nZNIK;)v|l7KR@Y? zJyzj4{cajeZfp6gjIJ3sOOt6+`YTt9hz-xirRm&3?lL=iBE9va#(zdY^l3~ z@!bgRtj>2@ny(vH%rdokft2?l&|?D0<+gK4fnqtckn7elx3tJEdAw2Y-1!}M1tl57 z`k_f1eAU8>*?M|)i=?OW zP+$j_py5iSC8{WIT3aGX4;c|LySrFFSSEKC{wwV22?={aIACil6=AXlDs5v`>fQD3 z7r-cHlgo-`Z7b*wZ}lUck+?W0VflG@(j$pl8bANkd}h!2Yep_U?IqGtkq{#S z(V_hP{mQCZm$kug!6b}m;*m5yqLzx`ohdK9>EVH0>G%tPFhA0{_6WI=3XVRMGQWDP z_0&bv)e)}*=;zVUxoytTD*dotRp~6GRb}Ph|KUzyv${4`c{4f5>`Bzd^C108=H{Q- zjW_gKH9UW6rbs_yZI+{m=DJ;AEQ-n(mS&}5m)Fn^sG0GzM6%qAt! zL$NU4Fl{sOVO+M!SR^9v=g*s}tb)R!5j4I1?WkOV?orTDe442!V?Y0DC{?Nz^U}kN zEH@FyM@@qviwaMEIpWC3UOv8|jSebHKDXoh48b4K*ZG=~nK|CjE8QZLK{S+}Z^niF zz^N6){xJ|7rkr;PK;5!=o;xIX0Iusbd*RKUcnlaami@KnO~YtI79+(#3)=SUyL zvbP-1p3X58;l*;JN}3|?e-@#pQB=g0-&QALVS7YJ{*$=rj?Kda_&c}K(`i0D8S%|F#}tqDu+~d7E*I)(wNIyxC-jWd|k0fKD}A$)BIW?4xd=o8Mko* z%6!Mm!B%RtJ>5NroH%NQ)|eRjsTqf{TfeaPWJYvBk7!|<+C}8p*#$jNY-4RLp@3ot zi-$3=E2N~WC1zrV!CSR%SH$@E7&x4tf|!%3sV9Y~$Z%2Hcz%jygYU*PE=GZ=Bpy}( zF4kmt_{=U^JvCVfX(bx*Hbo%mN0MkaDIb=c+-5<+286e0URD2dU7g=n$LQ@Q&ld}N zS4=fz7%=G=iMs4d7YSTCm~Bf!+TVl;rukL1%(R!pe3d^Ltc(Uk?%b8y-r8Vg-RRGz z=W&o%+M4IVn0WUHRV>P&EU#%8Q3YC&f1f)ot*-|x*MScxz*Zz=!Fl|JychU$kV(~9^#dr)NYg#_iTIJA6A;I8t=dex4NkJ z;;*jn*9vm!Z}QLY8aXPb&*cpFmJ^>p5PmK!tXzy}xc{t*j{ChFqPZQj3e3@pZe{R+AR9#qANl8}=Wn4Sl=TwEh?u*Z#dJ8~eSga=pH#ZM` z<^*2;d=re%e~P}G`IpDzCX}McjyTy9Z;RGvpUm7&t*9FexIxhh!1-D3xxi|Zs#suIT-r&M$?dzwxy|q(IsFwEO(02CbvHIP+3bT${lyVd@8)bRGHE(YA(irjZ_W19kM&uI2tU7OcOq=C5g6~5 z2Xg_beb{E#qhBXC!~UvF-gr-ZJE}wpNfP^Ta$VcPc7` zLD`pV7tzqFp7rh9yXxvVoQqn6Uu%FoG}#1B7h6kga9U6pf(Hr45>JR7G)kHUmHUr(u`RmYAj5{ul57Zka+eF(K znu`#3zqgxRb?1M>GT5^+!i=52Xr~XzpL(U^K1im3JQ>DQQPCwJ^TR9$!|6pqd;se~ zU|YKk|7;SZ>I>4<6)8a2k9pui#mKlj+sBf5$qnD|o}ONEK1&h*v6MJFLm$Y;0BNdu z#vDL`VN*>+*lxDWqq*=q7g=`SO$ z3#~l4^Q+q+U@uD5##%DZEJQPePgtYXW$H_>a!TeYek2JBNZy;ev%^?z7!%tK10A8< zZ#wS~BaKl@uW?PdQsWweBO{3j2x{j-N%=i+iPb7>$(p4B(gsTv4KJ)?qwm2u>Fobw zC?&lqPZm@f!0-_V7nhxd<&dr1BwO{Q;wF7*nG8!HmqgTvnI=pgJkF((k}xOf{_7YPdrDk>{$Jxy@g*g1#3 zk5BpFY=lPV?-80*I9qTMf&=5bnFPc@GG#PHi0c)432P{Y_m#rvP=OJ4rP5 zU(^*K%bZP)xu(hzekjIE6_M5WdLIXGD^T;d=n&scq=9#WbQdJJq9Sqb z>xHbhH1Fw5G8&q#j&NXNx`O7~hUAqiSHKR##pM+A!odG3HT4lTHa3p_R*saMyojVE zBQF@JK;BG?skb3QeaF6&r)eq_Uplnx-a(;T2xIbg}!Jy7DB@XvGVg6 zT&v3wqfWX>!IF|Ykq#!6W99-e?@i;hDowXY%qv~fkjH8btv^~l+1mw%uTtt6GWN95 zG+)FxzDX~uBg<p z5EZ3m_!-&iLhK`?l^d4cETaAQW5V*!V?u#I>>^BJYJVNY%8%qH&XME#d8rc!r1VEG z5V<(Uhlea2#aJ*+{9OcWZ5b+pml}!DKL&y)n!G{UH2wTj)4|g#r1{0X)cJ zk5xLSpC|YF*;V{A!5V?3o966J(BAOr_h5 z-|LqC@amO;yE598D2tu!a=h6L9_QmAHktIZt?r*;x`QP)#Sl;tT7rQoxVlyA8q6C# zTnjdi-|ue?1vI4DrERt|>IZ0U2W_8NaE6souC|XT+i0#5+rLZf#^ORRTcbH$`QupQ z*NmhFda#)v<6ryd&A0rF^62`&iHK4x>}k?TJA)foA;*qeWXE}Z$kyBhoEA0>1|`d1 z+x6^f$>Hvbg`pFRbL_P)y0N3jS9z36qbw=(ie53j9mtFhD2Pvvx+u)Q<23BBURx=~)Zn#T-%0hlRoE5(8jQQa&n`+`6u;2S+93Omra{E4k(EskQ}6;#cG51V z(X2{D&}3L}M6&+u^W*@m%a5?mJ-GK&`-z9e31iW2-8jyL(wJ`a+(Nxt6>?|TC=1E8 zf28Dach6V0mA;9nz$CRzjH}}Xdi3_ri<&o)LO7#q5)JjyPP`kjg;f0ci|Hy-yn5=| zvTGv^cSk`91>6|`g-?oib*&AiRbbWYGb}7EwUd{>g=WP#JNBs3W2>gnKisilHpUoL z$-0=SpM|p9VZ-}VuWM#HUEj;%wbt$!K zC~~YhH#jgUp*J@FP0E(lw(G6R;p8UKoIC{1#5GNduXDAc9|=Gmus?-FnS`XEu3Mj`VxlgUU)%E-37MAi}UF>&jP zL(%+tbz1&FhY8Q-%+Z)sL}Ti=Yt6wkK19RZc|%`AYA7#K{yg|uiCalsnkL;j`tw+2 zFB~~#?S2u*qS$OemZ3tSWMxDFZBxRxvvu?(aDk6;{pB4epf#|qB#DN? zYxs_B+_|$)QOzgIlbJLON^aoO70GI_fwIaSs;vy9k3<`trC*^<9lRZaW_#mBI5MPj_ zioGy+birbwy1S0K_=)6YCac4(#);BT_MfbXbe}$bYHPa(8jvs*1NM|b!NGzdSZ61N zab(da5OQX&0}3sp`Ew^EVu6(65!`m>FJ63>{~1Wpv0C^*yNf8+a>I7TrK{3cY)-SE zV&Qd~bT(&^Jt{RTA|Lk_Zn9AFOACf0JF0C+4Gu2}a`Y$5^MAHLw691W?D$bJjXg40 zy&y^C*Rb3q%7}!l3x;~)!otEpxE<(=Q2TH2|9zuV?e`{4+oL!!KA66|;ULJjZdoT)C?$1%VfmF6iaNs-8@FJ)Jug z4Y7_H*D=ziHCCZ8v+Ei{71>QAx^%#DR%hl{hA=R^*oYfTEO=6$?6%~iJ}U2A=hvoh zl+C77$flFT;mgWM$WT#Kv+yzmB9)1WNyKOmsHuY|EimiLtd^2rJ%Z5^rDBsd?5n>o z<4N3BOj6Cg634X-v;t+F(_^w#Ofomm4==kW4-L}dHzr{hZ5ZM??ht7f6>{c12;puS z8O$s;ePM}yL0viBnD;W8lJ}JzTu_-JNefT?!_aC>Y$Ix_sRbf zI6U0V5J14lCJ+`_%Tut)fL#Vt;Bh%Qpu`mv8mgzQUH7k3(l#$u@h%2u@k0-Zk?d7vV65Hft-b96(gv{iblw1&|9 zEzZdOrN(caasu-DlQ}1ch5N_^ToqpZL_OAGh4ZE)zt8EZ)pikMAJlqZzI<`Rx3^b9 zEj9P1)*O9(grG%`)r~l9QabTanDq8@76dAOUFaLkH*wO}?$+N~dc)P;uxaw=^tmhT z4<{wn)yJ#Pj;F%HM(Wp;Dm1#rR&FZi|75?KWlT7%(o~wxanr>)#il^=WR#1o__2|s z+tR(=0@Q-3BYU-#?_sl6M$%qC3ksrartToF;;FTiqCfuR@Jhia89FM{Si-d?+H$v``=$K2}AyW2enztFzIPF8d?jnUcsUDsibZ{PVD9l@qjYLVc)S$3^M z#~^mTB+n$7qp%f&O_`rIcOc%q;NhirJ_seRYCIUY$};sSf49}2W;~-}&UnemaMsg6 zm9h#+T`(KMx6E5RI*hDRAIFbX>zjfhNf3yQm;~p(WU=t;w{M3A27*%ZEfSLUf1NFJ zsmh6)IB;u-`J5{h^L1gM6M)g(jg1Jl;J1%-Nt&C@(JRC?i zBV0(z(xNSt)9#enB__B}LGb7Q+*@H39b#E}*Wq}0g%vi^_<9>L9Ijd}(4yO6pRN+k&d=W=ARu>pAuhfGR!Y{^>^amicE48y1Dou3(5??T zdvqr57AaaMknDWz?(16_%uT@mF*fE5g*J57{9=<1)W5SA=CKzBG-)3XRM_dLbq6AB z^Vj-+O_OuEj8eG0%IIsRNfODr&VOhAZrGi$%Vd^TmTg~%W^c|JFkuoFO31S-Dk8SXU{JG4nE@%-Sal$xbG zSf*5Ja%J>kV3QkuEXnEH?g5!ZtR+bk6%N~dhtsT6P!!=nn2dDZ;Idl0K}^hJyEXxp z49tJQW4ht{`?9~Ed&aXIbMo-yc6kaP@qkH6p|7!W!5%Iee{Fh@hxqLLV=N+d5Q8B^ ze?H5w@ZTFM2E0o59m!PLAHn_5xgP2rhkGi4o}9p|IdW&iW* zF@GWL4Q9Z48A!_}_`1 z_t~tzgpWeKaWkpMo3-kA*jMk#qp&k%;9`QSl3tUn_5xA^?9N9)_XD2>&Qyi;-Ct#hC+ zM;SZ(Lb#d_&(ga$!RaFE3NE{Yw#pXelDKrshYLgoUDV9sUv_c+d}7`0wfb=or~!5r zi`~ZbPajB*MoQqlhlvB?Vm|m+`omYYZ#Lzff8wL?vJLP5i}=WY8I#8~3G?w2NB)Gagqca3O%#U(Sful02%ibeEi8cD2Zw&=Hz4YcN&P<8HSSaoIyoh!2yki@ z0~gsBjeF};U>Xo3F>rXWgS);kV?4)$Z0TKGXUG04Lbx!hOzU9Ot32XcMdl+8gkYL< z|A_9}SikPs6xLizFVcJ-W9Q~Q+$lHwD`^cW^^&LN)3+5*%iidYyxWQ4ke+b}-mNf3 zO15z`WwlHfBiwmAd-q#6t#ykaW0GF4U1b@`8a+JRzPi33hg7n*K6C9(wuLA|Et6bV zUk^rkVd3HK9v++jeoD2|VD6Vur35r{P+$aAakK&E$z4!XQ(Gc~WoTW5T`pN1#?afp zxW=Q3WE6JJwHaCM6Hp*|$;)ZnfFnL5^^81-wuJ~$kXGx%9yPMJ+LGWz?09txC)p~z zY;IDmEiKOY5;obv;kIa#cvFdmDgomo`mkGkTI=s!jQTCM4ZC{!GuK7M)i!Lla>n*x zQ_5CDrc;9fgmY^c6Br@Gd^wA5E0};H(2ss!a$Az#+Dch)006m0*#GhQEB0q2mj-e= zT3U=i(B{^~gapKQm^^*XVpQyYe7gc^HW*PX&u5p9z&0h8;LY0{Q02c|sFal{o!(1B z=Jp}x1FU;|eg)xy){-}BW-%5C$^4(01r@89~kJNb&YLAl>WROU( zNK!(rdOggc!x3Y){WS?R`76CdWg+U|#TUehPKyIzY*dpCc=O zE9lnih}ZIuXtB^uSz=jk_+B*i)rA5|90wiFNCwg+4o*&3!8U-xRz&=M>xoinJa0>o zWrgC` zSJrZ!=Ps?QdkzT&AoKRI)m_2aroijPbHf1Ss?(U*RxTK-sBD(67*!SkYLDZcJFe&IT%ypUvmX!Q|-e6a=nXpWche z8XqybqzR8vy%Oc}-Q6Xk$)1b@Vlc~tM)c|YX|5KCo_TtEgRDD<)?(w~%@JSteO)^+ z!&Mk^a*80>CCk#JNJJm(g+gHNo6@);=T$^{$(VTE&O$jysKFcFJuj z!!=hV&1AXxLKwRNiW4{dG`j&#h%ziY2XIwm{Ko{n(U2R;rlk`~P2oj4HmhOG|U@p8F~K{t|b(=Lna-Z&D3mda_oF2diWk+!NDfX^4rfi~b}# z9VPZH($P=Me)RShfRcjJz1C79PjxC2b*p8Ww839|(ek)zCu)Z_vELPiu|0Szk$kqs zKQPxtbab8>d`R-tanFliiy3L|O+Qc!|4J>_oWY@B6r2R^7t5$(G}yz~vi(6-ERx1< zEjaFu+JNMJ&#Fe{y?u9cozgA8@Z6!?G+|cibmFQyhgPGL#?c;9L)+@_Gkn=EnkBCD zj|AvJBKpu9k5NvRTAoCC#bj!AB-KF4`5;{@R;|i{JZE|b7Wytti{rABp$Sl67QoE_ z@9I@x{4W5u=uh+T2n()f{~xgarva?j!*EoAeUP>da2n76x9zQ1jr zU@o{{Z6$E;N94Q@qM5q=*utGOo5X~qPdZC}Kr|nZIJBQyP6W9w#Q*u5`-)!@<%=3W zJcg~Xfr=V!i=5P3>QQ4##UYU3vRFIYn$LQ2d7Gm+inD5sCWu!O5M%QhbvbM`wF-Wg zO5KGjQViR7f1mKtF~n3F1j6Rm4|iD6L!ieABG9+!UVqD~k$psaF`I~+S--Ar5J!Ng zmKv4Pr_e$iX|=~~*`pqyRd(H{ zUePX%pM?D4b%h2~!cqa0U$Qi`NiyqmHul1Y)8kHc&U0EtzQm-6+{2BSwpn}MF=inw z!UZDa_?t{WWQPFb%O+7Cr{mVOYMVq&lb2(#*BI*#+X>mDE58e4;i6bI=RetSGOR9d z`k!XiWNcy*1MIlHonEHmL2Kj6HC!Sj5tL#TrQeTH!hs7_GG17{(i%X>k z`MurQD&&Y4kcFPWq#aSSN)s@SM}5y!g%Z8m!OfL>=rfC znZf3!ZmG*=C$szi6W8WFDS7z62ebK3&KKdWaoKN#+F|o;?z~&TA8am66Sx{|rBh}! zrbO>{8GRC0URX|9xtenIn#0_Upj-BXB{nWzmPwS=PEh*mZD_EIy#<&pKOlp+T2~dTU1~QUN=inppHA~ z1mVZ~Nun=)y6T2+y0?pRFjK#(n9k=X%4`pY1_{DOUgyr81C$2h5cu-&L3#rd9>$xl z;`iQq;4RCkshQ0Vw5X$xpCw6OT}7?rn9BhbHKH0nc4Bco&iJCu=A!+>{f3c%kxxQb z+15J*5dHg2CP#KX!^H!y*=(gQGtCBfIWQ~-=EvE1<~KV3CMy%3l>OuOZzdJL6JTk!uW?vHM| zM1+qI`ZCU7kjceW4m47@(S1=#B2TQGQQ4UMpNme$$S->6PYMZr!Y%5@weG*os9x@n zlUX!sTQhNis^vC6rpLBA%l*|61%Y84=O1QiePB_uvu8}OZMp4+AKWZr%4NC`RPQp^ zIeEd`K3t$s35IysOh%q8SY?5{K1>AjVf}t|^aXjQ1EghSMn^|^xVhc%Yier1uoqm7 zt?J_;b0^QFd<)b10>_V&Le&7i;=lg9)2a>Z#%T(~NT`edP5vqLNgU z$Cf)ED=VEnFit-p%LxhR2TJ)knWIIAUJAt6ssJT*Y{cX+tb`9exa}at(-9{|nO|J& z=jXS(y**R>`$J%?qU!5SF~C5 z7V&{3Y3$fV`-|Oa|4uqiMIe*mSCl$_dO3IVh(VbJwHSN8tYUuI7{$F2IS+R?DQ{z~ zkKAB?Ap%!>q%=k*>TqVrxQ5=LuX9;_sM4uCGBQ$k*AhORg2Dh;_Q8l2WOE?S0-MQz z;VWfp$IxI4hr#!1`BXl(Nzv-W(z2UXKBdsF`{CdY8;SEF%BC+xbGH3E3GK%vb`*ek zZDTBmoarKwtg;}h1&D&u+|*nPhvT?Gw9N_>j_Kh7g2C~yeYz=0CDADa4OAT4>FOp& zMxsuR583=XJ39f%fhe3Nn+~cUln)=S{mDY!%l8U|G{8U{ziEV0?!b-~RMBhFVn67CxH8Ve1;qr?>16Ng7$MdA%dqp9x~1KSu+ zohd9g8{t*PDUYj*JQ%@+gn*t&yyx@h&-V@viVS*wj*M`Fa8W@)5b2-lp@5$sYN)=mlQ~(NiX^!uJfO#e+wEC zaR+JTH*F$Ytrv8d)wW+4Sxqs(ON4e_!Zo zA-a*c2aPKdLLp5obZIM~}`HR^ALzuNij8Q<}ijIqMa zzR=iii4a;EUR&dj_1L#q$|O)uZzPlQq&fdve@J&}aNUfcoDn_rHhXTKUMumNe`VJp z@y~<7L+-5rcxZbDI&knDg=uC(rmrd?dUcbd^%8eiu^kd8zKfH8GOQQ zll<0?N%GlaCE{j9dIvcpx**Z0y5ZT)W-M5h*%l)NEClXnuovW#?bl3je~HKMbc7}w z9XI(fGTIlSQ%noIj8QIa7^6m%$7c-uyZVl5~#(6^wNGi)nvX>Hxw+B(`n znX+Aj2>=)$0f$c`Bd#;_30e00p?sR-90@oagc$G;@*!k9P`CO2ettKgDK2w}QI$aD z5?;Kx;Pm{T*H%f(l*^4K0h~WFt8$61JehEo9uln5o7Usb z!xN??Eu-kz2pixwcJ`v&%R%!=yx?)BTn_RTOKI?$0CEMU5|Y~Oi4FPsQKkhXB+zBX z-xuEsICtv5i10JC6OEC}ex%N;pLSzCZ#!a4C%rPevRBUs<}$#fbaGNt{2?VJB?M(A zCMjXz6matx8ymyCggb5XyMle6l0(ujy}!4oQen%)!BGl3Ids06!!tpR@xLR~T3Af18S^oyU29(5#!((FUS90muAXu228BT+(H(YJosP9t<73{3hHMN? zknHstT21}*Nzw!wgQjLYh{hHbfwrBrrsmmqvM9fMx~{B!Kx+Q@S<1oXjNJ7IqafY=J1a`3=CP&7r^<_LmK76KLI59?j6ZJxr zAzEs8k#j$e7XZsPHCdu?yEAP4Vw;1TbNj`QUh*lfFAjx@4%C`@x~P0XJXK=Vv<^bx z1{fCFuT9iMG(r;iXR?Nn&2VeX0S!uEk^hAJc(}NrY3f7F#ST7(1qGH9)gQo%4O(7Z z%^Co;tjdq9EDuo|JB$26@&j?;jq5ObcV|hQ<+q#+_2fqxr5k0j?3SC0G+7}4UC>le zxapVQe?=1_A5>IeL`EE0rNKZ9*!1kJIi7tD;GF!Zt(A=C)X~$kf#?mxJ`wq{S5n$d zQC3ecclD1u)J@=vk2E%R*7Oh8v>!~)7}=nMd7Ll7S@;}L9;{6ZY#-voMGxV2I4bhA zFSFu2crJPc&cYo&*`eQNU(WdZXXJIe$VgG z-4pW>3!GKi+mu-3sOQ+l^D@sA&x$!(WkhbBRJv7b)~XBC(}YoK8UVwbDiJATORG}e0sXh^-eSAnmnn{8bAxOwZ*|Xv7wd1<6B>km)zRT`=4oHcUq`Ca6ljl1vUXRmzb$llWyje zAm66yecu>d4A=+|^f>8hNGXQ?gr#8v<2rP#`OJ5>s-)vGQ(wu;^5OF@b5|{WK#!`Z zS?&}WlkAIIhR0`0E86WX`WGBoieF-0Rp|hTWMjE#A_QruzmxNJymGXyO+4SB49x8_ zGK^GI&Rkw^UAQW1cnDO{B9PnsH#7;$OK3#>d{d0HCpDEA|!`=0av z+80)~@S*__s;$i!7~K&s^!4X6L04g8LzbowiX4~%{r9b>61_=E$_G8Sa27qthGw&h z$GZFfQ#N?2FL5*YcFIf&Upq8-XINWGo8I0g1ImO==x1H&cTN5}ft=!w-)4)Xlw~%g zRUbqQxpO#~>-$rZOcLCy$SXz*pB#gZ!!BAuAm1ekXsGn`XO{~9!Q;oBpna#V&iCk1 zBk-ad8&l<72{$FGI-k`rh%cYs?s#yP`hD|VO4mh_%q~q3ti5~%bQSVS+v0+FSuYz) z^!KBA5!7R0!Qcm+52Y{1{pn5XTJWG4E(cvvR4KGF#*_R1RpwVtCkIPdR<`&3IZRHl zx_R~Lo98KP+2lfB&T8-1uLorg-QKNMHqIZD5|=oB?+M86a~O9>BF9*y8OMCVHq`12ZONFw~ zh=fV|K61d8XlT+Ea@5pajtf70sA&$SEqoHA+utUCRq>a}XeJ4&LebWUzhRfLqr6~h zG^hW#z_}-_gEaN~R^!GM;s${4hKv5m8WYsNz047>D1Yv3}WnYlb( zRh5>u@a>taMAVZwS(-w&7@J{aU))fJec1tC-Bbs9%Xg=#${}U8rQt1@DVk{R&hSLG zZQn!ce7SM&*4hpvZ!2vp!>q0+LeAo5V+$Ak0^yq3EBCw~R=(xXR;p`#PUaRb!UWxZ3%5uC+d!Z$ zBrPbpm`}BMtE~W)AWIXqqt5bFjq)~sfx)n@QYdv><|riK#5Y+tTDSAr#u*^;^z>|^ zOso2yL-2e3IxH|SFe_^XAj)$gQ1Hs*MWO?tDEbm9&KDu|5E*$dT zkinwDG&$Y3KX+`Vemd_v>(;cLs@a}I?(Rli<+7UCAQRt7m!Aq~nE@0t0!IT1;r3{* z6pqM<2tznbO;7g-26?L)cdctG++&Rgix4XZSrN2L&6Wcdg-VyNjU5Zv7}8f zRb*#u7D^iEClBPtEe9dzpX7({v^U}iSgu1&-7}k#*d}SP(yMfN{a-m_ulwiC!@x?E z8LwKpBRMKKu0^Rxupe|kfzXWfdK?)Es{M~R2-6-x2H{!2cQghc85oK z%U1i}v;sTrw*SGx?USTGG;_z-d_M6`JaJ9!R}m=Zj=%XA6!_6O7oH&c6=mz~Mf-`3 zXA80bs$9h(y0dt<^4GX2y57m^&cOe(q|mTYu1Px&AW1iTGTxdZ-Q7d{k-UM`-1SR* zdzBdqpyfc8M|?pdmz2W8#;vz70mV>?uR`y==t%He$mTJ z1uI`K&8{tP=PP$Oa#Yy_$riBWuMEX|0JWu8?JK2#D5=u$gGpn*fqLNL<{rJ@P{Qkc8eO$-y zx~}8C?*DGbd3Kz~86WTWYdpu}`FK8_sMu*}Xh5VH*$k1I2Y|2%Gmwa1hAE3*RQ512 zfeeXh58oPus`(Ed+$D!lX2aR~QHrDd#Bo!-YUN+2pI>V6uWax!c69l+Ys;}MDpx+K z?S1ay>RRX$5nQ_cG6j1dyissEeY(JR!!vbbLNQ8YQQT4GwH|7pTYPCd2GE_mx zW0iKn@XZ{+ur*n;{cfx@a>etD`wmM!>KhSpDJXF(D5`Q_?)!ADL`jIW{3F{$Zujdd zm6abAkACvFT;HImOV`aQ`qDd6s?yofMJI=HMdkMw=~@gM3GyL~{Mju-C}sW^r`gUc zKR4HLr(`#x)gOU8VhbIDh%TRW+YXk?t0dU701mHE>6%js`oOjFb!hd9=FWL!(zr5BnPq8U~{c1%9yD0A59h$3q5wXw0tkGfep(S?Gn)oyaZJeE9O2oq7>%l1=C zmuiHMstY#JdanGVE7iI@bv4Wx_3??Zv1dLT-YT4;qAnkD4Lm$NKvNp74S~5?{Ywo> zrY~vMq{19ifG2Xd5!zET2`i9;-7E`1c1$at$26$aRP!1?3Y4*p_6CZ@*n2N8@}GA98&V&FE6irSI45hg*s(GY;$&hnXLsxU7!`MX^{?LJ|97%AoFkydshWo} zQpIQJcK)KEHtFqOJg~72MX=wC;Wt%_9>V_ak}GR=yc%Sq>-;a~b@s+u(2W~6Fvb~s zeB#6j^t6G_RKnaHb9Kd^db^vgx#@WCy7)we8ylHalbeB~OP5|+^_<;QmQPi#O(Ma;Fg@9zqC@4 zL>gd=tuj?hL^#ZMoaHal2-@uB8~pUemz#c-JY$m0yB9V8yhGXf-zN>@qx$*Ph0eH!9u7iJy#6dyDfIDdq+X6t+m+e zh3|J?gY)`r^whwHTe6OQKZw(;HSyQW_+BXL%pd=c+H&4nRZ^9iWZR!^)joSy@;>a2_5UO#bxh3;lT26tj!DXH9HD5(emG{D?6TYI?3;p6g?+ zva;HFK+9Y5!S%T!y2*Y25micyu7j~z&gHT3kQ#^+1Bd&*vHI?%;YfZCrMoo zgJr8bj`ZM%NEp2t?FZG=!U_r~|fGD*CMb1qro z`fq~A|NZ_XAI^ZzfAuGQ7*XqvVjb|>7-_kx^z+#Jhm+sFqN>e=f4(v4-upiQKfV~_ zSyzV$aAIQOfcNh_?o@T&nwpx24F#}A)E3755g0CmC_L^PeWo(o>9jJ&|dqjWk;*7~*T#N;VsTk7wk zG@vgoEbNoKvcH=0&q^Ql+8E^5L73QnOkNK!-5xc;x^oa2M zTbC6bvLn)+N*!exy}ADur^@W*rLoqrT+{cOC_jt64N~ggZg88`(k?)TPi&p04eUwjp{2f zd%ZG0iIS=x4I;+RsK3A0{F6K;(|fjjzW?Nt>fSPkcOSw`ZG^c?{sSwlkX4cPd}ehQ zB(lHd^@+;GES0Zn7JCUr-pT~R2wp&hG+~<|Cbb;v8 zMuBEJIpt3>$AsN~Y+3 z{`KFLk|}Melcs|J69BD5z<8D|2jTKFC{z3cp$;1~Xj;B6x|T<7F!z=x;i$=P)8hOC z9py2uXQg$sBXUCXK8xGsjOG51YsQhpv+Vpke7$G?Srnz(KT;-&@4MF<9&O@@5lIth z{6OAwvHOVtq@Wgl<@Ven^(yee%RS0n~ujGZWU3_ z(ovIn{ZAQLlI>a&nd+L!+#rk05MA_pi-m;+Fyqj=vC8o01|**=SwFH)3=mT?z4!a6 z6A<%9+LahOCr7^e^8c#I&5M@z|HCG?b2*NedVihMZT@by#j`01t_9WZ3t-P!Zd0bca ztHVF%C|P$;wZExqrXxD_?=sX*ao-Dvzh>v3@(H>lvD(V&dZi0|KI=qGtXnz`y(ARL*euvX#}s&V({zHeq~Zsc+TdSi)%^F)8~F za#OM-+jEbnYr1+@!uKv3_f%Z{DEN3dHX#%0WGjnufHqsK$>e1KY4Gv!qQMzhr z>lS-{t*<+)@pmr?Y4zapL*0hmRbH1 z-qX|LJl_1v{qN!?(#?mb6L@)V-@VH!Zm*AuO5%$W5D5S!&D#I#TiB2={gZP-?+qnB zNy#$T$q#XZ;jl00z4-S-2bx?G+Jok*xvQ%xF^66?Hcl0_$o!t~HU*YbLR3JW?51SL zY+Kol)mfg2aK&^qx{dy2e3BfMwM$4xyX&VdVIVJ-ULL5bl6C=Y5R^M~jQ*>s)7|^) zTAt_ZD>Al+GG2%P;{G8SB=Y&>^LIfl&;4ILqb=-}jHcH${`98il;1SW8)M(p@&YG@ z`_Fkube2~BdoW0fL;r)R|6iQ_|Np=4{r~HN-i`kg=Rob=LENRx8^(gwSB7prwz0Ar z8z0x}KZROu6u$vSbVGXTfR8K6sML9zNPG>Y+@@mk^YhEgrL=xY{Av0dl(DuRNB>gy zvXiWA^*5i@uH8_>4&8Sw`>X~9yZ0Kdn9rJy^pBpWBLF+zgE6j7hT3nA$x+WaoB1s& zPgi^rj<`)N=}i!voo8h?7|VfsG|p-r$XO?QoYB(Qc*mM#KMKXQwdn!d|5#|>j9+92 zVwL$8(TIA%!h+L>oRnm6Q+x0jo`T3M7K~QfxObXhH!0g2ccEz`)UL1Cw&Ssi|B~#& z=VMj1v-dWtvmn_UkOtF=nwTr-c6?=Z735@Qt^Yjv@=7g332 zI;9n0_Q54aq;xj6Go_h1gW$Qe=IC|R>!)e^sbZ;*XH8pVSIT}=Tzzmv@5kia^Uq|O zaph5MHg`foIwoAER5DpG;{4Fc?Ah6O0Rn$^j3CHT_o><^i8WY(Bdv{>Ht3D{;UZTj z63@!SCePl+$aN|TiWE9iY)kTH^Fw=~@I zGO@DW-IeqbY|H&y&ns;s;cfXMhHi++6^XBk zbPHM`ZY!h~*7j|A8PUe*aGPjbN+SN}VGP)-*KxE0f zCp+;;i={A=z0{4$l#zt9oCjw%x&sLOLq`drHrpccmgLFE0whBlnwuZ<9S>-Ac))b{ z=``INesgK)M%OvI)OevJ3n!Y^784PnJ$$4{!s5xM&R_Sl^}s(kl@=CzCsI5t^1TIZ z&{pwXBt2Wx+Rj)WtTZu=<4=r@HQ0BW)A?B6>aF@-rl>t+ai2x%90`&2aS~<@?>fnt z5_a%9$4fhXxowg^{* zqZF8`vNW?I8b(j09mXKbqa9kIBVWHW?FeYUPNl%HE56qJFvUwHvqO7qs^7HK*lzl8 zq3nsgWA9-9)#Rj249uNj2To^p8mU%B%GjoJjg9Q=9c9Wj&AhAcFk$(F?CX+hPQyxc zS&sR?*GB!s;9hP?E7F>eI*oTD&yAJ9}P)( z)jLkpJs(5rx11B`$n=<|$sH^Z$aJEfL$z?KoL2j*3z_!*S^j5Ey8ELWEQMqH6-)vZ zw~^3n#VcR=*_FI5Yrj6$xb0rj{(BL_UxwvkLJV9e)@s~!qN2l`%2}3g9j~48&*awe zzC|5Brx(>z+k+rQ z^pjX_j&Fsgc%XTfwxXqjcY&0lZkD!?OSjlW?o>)lH1DRENry3q)8eHa#k6+KXX*J| zB!5Wkfe_x;dCvq@y`6+dCi?BZpTOII0QEc>42dAaoj<=r>~&C{n(SA4wc_wV<= z7fY8=5OpFp)!SotxY)h|QY-cu%^L!UK#c|Ucz0(WA0&Oz8khDaF_6hY|HA`OW)(TP z%>r6t!IrO_HQGKtN*iN~yKVeTvrJ#7Vz`iLx^AVoShTOTF?yl+pohErAiIw8yk#bw$3r{>Cf@# z7nG)I^?)^5rbnVTb<^a7y$C0#U*7#ZpRuef7sAP#o~YV-GVmFV6qA!#bl+TwpLCH< zP_<6or=O+#)a^7?u6nxo>5LAI+H6r31AYd+NJW1+_SI8l|6IdC!W9-wN7Y|QOSu`5 z6Ti2tV`rUrNjt-RFwN67Uo~zj`*E{el#`Z1>+$}OBcxG0ZE{KS>mmi3yu0`8_d5#) zwr-Bt;zU|@cwk`Q8BWg>VgLPFp?(f5b|ro7edpZWC0Qr8^)t!AeK=ba1gD2)=neRRpog}cwYY6n%!%g$IKEB3eGb8-&jEjEsg+C(5CuSYxh8#kS=hdYHMW zsK;$KiY$vKIl{`#k7sPMxhmK_S8R9*DEV@=O=tGfk~@2`7}k&E1PQh-Ee!m!Vz{`)$Hm1O>X|%M-B5n)NuHd+CL7lIC@`jg)iRTNqwiVU7xMP zuUEM_`}KNP12NmY&8|e(L?}|xw&L}^$Vsdb8j@k*F57T~ok`b}I+t_v-xq~Lbk)!Q zEa6R7@Yx_kdUiiM9sN|2c%g?FQYFfKcUy`vN7$iRs?M0b^53Ohr4u{`kG|8PzUZ*( zc6u-C7|9QbwGJCmCMG8C@q{NjC;r(h$%{81L_}Dy-@4}FB1(0{|9N&n#A9N0v$In> z=Be+@g+qIY>?FGS*b8CCf`NxLHYMF{uaT91LQr!q;+D0-PhGnDb^OzMSn^{X?&fvX z&TOJ%*}4g~hUJc>7kJ!iAb4vN8dWy2zp)Xu6J?$~$j`2jr%TCsXWNywP+l1GH}AAW zMYeQhsB8EJ_aM~I5e`f4<&BqQZ)nH4C5Q#dpy;D7#A1J#o=Cl@{FNj(C1UMK?+&gHP^R9kalG^ z%IAgDnnx`f;kksS5j0=0JPYe)aBDt&dSOVH?B!fs{qg;6}U%uQ)OClr|e*5-qNv{>r4GTzFsb<7W zyArH&UIwTqG0TygW)4dV9yzktn~R>V(qyB0W9?p2<_V0kb&)PN4vm$)^75M|clh0X z$DSLO*s;_d^IC~mYdC-CQF=Nkl`z&79g=5^J|_9Bzn7gDAD3|b)=4Zfpweq|^(FUK zke2ryi=+Mpa!(Vkk&+T!NUeCO=FtZ()wJ0}4B-k7%##8pJL>kev(&FrVHmQUK;eA7+`EG76Z z?u~XEQT+X?YMW59$Za3J3>jyhY1%jEw~5vzySUdqWF$fS>Qh8P58~#y4qSpQtAtu#)2nJ0r?W^)TedAza+C z&u2Npc*VrJbcVk&-96YN-@4b!gI8F1!r$8zZZ@J?wB6TR07FbdV!V$ohybKx zMDpwAmj*T^hGXXPIy*XpjN}*_l~aT7-Rqv5zn|%QK4sTjnxsa(qQDT4m??=Z2=c`gU*AHF1 z*o!*iry&l??~ZrhXtVAQIFWp*D=cmM}*g?8w$J$4dN|To8%<1^e-TnccLm6C_7jmxfXDdcz9{RXtQ_Vnh`-ICV z6YJmSc|t~|@t*pR9X?e;vWbH>s7Jf&Xa&O+0en`7)aIb=Wv9u~aWrX;ZLf30t&uA` z<{ZEKjP^^efNOGR#Yo+Mti41Nwx^zkmNmOOIWb z9S8_KYKgn8A^{cnAi)>2>s&(XYOYULoY=*qM~`|h4Uvt1UIl>xVsRfZ=*#Rwp0QxY zhwY@dHkl9vslqd1$oUktEiaBOSoJ@V7J`zW{GjkU+Gczk|rTZ`Q zxZySbk1ssXq+Ji81R1-s9n(d}3Pe)CLH?^sA1o?he!J%4B{>?JyYV z?j9c7wr+Kq>Tz6M`USQ>{X4JqwZ#GSKTn`J21xtn&6{7mcmZ*eZ{7K9^z`$!jHc-E z`QUHJl$M;Fe4#EB^oF$4_ZZ#@ASo##(HX6uzUV@zB_UBaz78<{8p8?tO)q&wM8fZm zAYDRox%tI=dOlq=J<-a2b|1N$U%l>$KF0)UR}McsjDT^Oy3N%JyDkEQzBM{>K2#GC ze7q9?W5*8(n5xPP6S8xs+!^4UTYtC0+y$+y1=X61nlTcsg6k1jAE)x)Le3(k5-M?` zW=7OtRe{)NC?_Wew!p-D)T)4CR!^(In2@t0V#*v6zCfF%$Vn@;D1~y&>Te9I*_>O9 zkvbjyIT~rF&^k6eG6MBEap)0ByMAk({rO_H8EW7O-bp|?GZrzTtpO4xJ~Qumi0jso zp@s*iU`I_ER4VkY(R%ZZyq-(OM^TK2y=!U$Y+=>{qczr0bt`HQ@l-zC34>dI)LBJU zm0^(8p|`NMqa&O~_~Hlt^<_1gWxvl0Le;Ar_l-W4US>0*s+t=9h8{be99^%;ywZnG z6X5<99w8F0_|O!c5qrcJdW~DRZoR@{?c@!153p~) z`(U-=Fm=tuyL{_B%Sj9WwOhJVxo|-hs9UO{kHNztP=|%|QG1 z^_eg4p+Tz>rT?J-osAd@U$yx8T4?n$(V>DwHE?~l>E(?r(Cq$6dr$D1?ykmn^eLyd zx_rVQ&2yvA8U`9!14WZ@Y9fBzMETHu23q4orn14Y>Rfji5F@LNCbW;=juPLnULsBktdtg{g8T+ zax8Ei7#^nEQ4~bRhDS$B;D>JB+-xSZ)2aNSZ3GduGR!0U>*W-uN7N`q-n6vHZd_km zag}=qhpv*r8cDdzBoaU~^R?|j#zj7y+U?ZTGLXi?=>l8+tHnyXez}t+2!Ia@%{TDN zsv(I4(JZ38$M?Q2u6^g8>MQG!<4oJUd$P?ZcE~U+p7`p|yaj))-e;G$8Twf646<8c zE8{M?hqQuqI(9vZ3LIgo85aUirL`R#$vu@elyd>(=c7lB3nIt9fB*FClF{s}*u@W1 zxcvge`S25q_;`ci<&CwUKAkH^goG|bFJ`m7<(P7u=mbWfp*X@grS(c}LMz~uHq(e8 zw*5~6O#2uKPk1u@<{{vm>HZR*-CwU_?!ZF2KdeFHL#d9SMJe+)w`X3g;#{G2fRR&K z8fgI|mx(opCrcy^qcgzc;MOrZS8hSlx7U&US^rZlk*hnrCQd{O*H$7`Z^LYH!JH521m!)RPd2G6Nonzw}=|?-v-T zl-`aZR+^9V4)*-@d0Tw43CbY>A3qw$b=1|>@ra6cuHJ;bOitb}_YRij9-His=hd}1 zQ|L%e82&Nd-XLo@C`ny1M8MQDJfor8v{U6xO!~b?O2r(pFhJ1BZk2-p4PrzUX$E*M zY!Gz`*{0FcdAdc@Eu^<@T%wjD&C_j=c;pGA;Afx0z`|nxDs|jnE&nw})9jPIlSY9t zZ^i0y54axcx?$6XUI(JmbsaLQHJ4B4HcDs|lM)ja65ocG3u&mL`Pl~7II<*shu6x~ zOn;EUWbfSd=7uGf^wq0ZRLr9Kr49*^Y%g6C`*!Ub`uWo| zT`_WSaPaVjw;wC7b&n085EO}z`r46kZ@C)BHC>$-a#FCYT9uyjR!wnKY%(7eqGOL- zeL{8*it}gyS^!Rc;ub0FTps>~nXqn(vt&ONz*nQ4Ev%45qH5n+yg%%Wal`%WMu6mJ z1f#n%H4aEiufSM+cwqtWv&!K0Yq%DI!H_o0LdgtXHcu1!iiRa$oR;xjrr%T%U_sWs zCaRaqM7~K+$wh%k_>dU5-hl`3^(7G!%7?=M_iP&_L%|}aA4rG3KI4$q{0O5DIzYVG z#j%l*k;%#aY#ks!uFEr{+vj^`8GER|=A+XE?fx5GdZx8+-+KS5WnA?^1ALEb&M#=7 zryr}Vv9&}lkiKY;rI`-yr_NxWJ(dw=v-QzvQ+QlI* z5?~46z#wS6gpY>UTk9+sO!i9*R;RvUY9KmmI*v?XCv3Z4{Qglf7pEq>7LfoYjD!8- zxh9n>&|%44F2kh}G%U_U-r`52rl#gSb3x`(U-A2fhN4wn<;#WKVmfYa&jJJMKrc~l zX578I^`(*D{nF2#7s0>7+uyl;JGo=PV|MJ{9?w{kGN0ok0iZ$^Clcx#DfRWd9dj-3h?)T zdXNM3r?5zX zo5gW0pp^~l6oSGlVk_$;ueYh3(^pfsUTh=g_=B&&U{G@!M-NOyc~Pp3SlO^8J08%SQY`>$c* z%r?yCyJZUF0#_&Fz1q{1xBz>a{Jvs)W0WF4gWmo8#+KuDmg4O=61d#Fc(i?BtFD8P z43F{r`m&Ro+X4cHtc+P0C6oG#PN?V?PxoES-$q6jZ(4w2FD0Yk4rbAJuV3HtS;zT< zXkVH4nkTLttep&0z9{IpiI0!}f!I~rad_)w6_jbyH4lo=1|bP|99A$f`3!;bC&uMY zSk_-7by^papfRG$YkDp!s#Dc=9kz;&iK!O?oX96WypR8aX|CzOAD$QmPHhT>(v=lo zf0^S5%2?#=h$s!qu9yXNlRt_?I<6LTh)5Kk3plr#A%57hg8Hwm3zx!9z_g^LrA6}_ z)M)P`rM&ra=UsOF$l!x244Ptkd4^qI%;nBB#fkAiJHBD0?jD9@3%$q`7^8>sF_#Vc5uguy+3~lk&J}HLgNvx@*})^l{H~JuYxf_sJSeG0|$#F zcjoxI;B}8?7$HdIwM8@qt}l(;!-iu?%h_E<*M0}`X6%3_AgybB4G5z|LGxdBfD;3V;s(g&g zh#-Ei&bIV=22>Rt*_2Yx)l*=$WzyrB!zWMNzVDc) zx@@4v00jooRT^ zVDDpz7-Z~3T|!qqv85CwIHGRqr{}~0C2-8}Z8P)pkan~U>ZW`Y*_3cZf{*VaE)HzK z)x0|2u6`w|mA~oqxH=}nLJ@~q_vP!?7vT0#7Dh=X9%^mi-mD`>eE5(h`nP7|zN)ga zo*G7p@|ZRplXcPy%VwmC=_g@!{aUK@9pc2wI1k0hhykCCPc1F^ z=PKM*)m6AfF2pKFK{cQJ@IVP2HUHKre7IsoKohILBYy_r>a_(Ukkg*KeYbN;?2S2k zZ2=waIounT6y*lZ2QCssL`CP2`ZI3p?hV6;xC%|9~p_EEdf!MbZ)|;0FCTi1sOuQyg>m>+c_(hBDn=3z z6oc^ieW2P0?ViwzHotIzPbb@AxOaR`cL8xTC`mvQTA9)sG{lVve{Cz>yLa#O3oR(v zA0hSitXzK+M2G$7p7+JA{4|K1X6L?-h%abO83MU3BRfVMcmJ1 z!KC|s;C#rEpv0rpALYKjx(s(@iqz71Z3VZt7{3%<_~Jd;LWI}Z{poEJE~}vp+(Y{> zn48CVhebwO{;0GZZvGie=)(SyQnL_00U_H40-docZ@Je9=%IapgY9=81F?~c7nN<5 zv$0I_S|GY6^sKzzX)+;KU>!3e$uRld|1VPakR&9u+`KlJT|Qt6E&ppd@J4VNhkb&S z-ejE5s_5O?{17&?L(Z0Tv)tq2OIq$vUGQ~ZETUDBcV&R{95f!2SvxSva{pgHcS2oJF`s$}}9h`oCt^^hy zw-MsX2(1Y&(gZy1Lp21BQs!<$GUks-je7kx-5yxu8`oNeT;#OJ|{=xQ>{Lwo#~jwh&8#O}5viWw>k zy3xa;5Pk5YvvhQ9R5i}7oPI8NQ&>ImWd(7a60x30ZM+;Wq@ zG5iGtm~#zFf*GLQXt{36tQ1K@OG9%$)eJ(8sGHKr&3RU8X6+u-gScouf+J*F$RPn? zC^kkTu(vgIAC1&Ao($~A=>uw~^pIZ{DTU|!x6X;5aKz6vv)~_2QVQCAYYDs@f1YlD z>AZXZHS5aYt1WJlZc{xF9&0ZI@JOI%=LF69*fG{FOCI7Lr)&1YKm++YT+vC3Mwkb-uSIs2;d z$@F^k)c*cnicONO8+Q?9_{^ zVDtB4oh({1;1&|Pcd`S~X=RS=!s>n@^{lP6NRg0bHB*CBqQtHi-DDs6q+^1h8Z1F3 zf(9}iTsT(T_39`tf*et=18#nEx9m#78_KuFfj{JOa!Rdx()}tIob~U5G9ZYhRaDjY|jwdO( znpcU(80(*^nWZ1wt5Yt4ip)n)S_i5cy>~M*swE!vfHpO>BYf7EGjLZZO$(hyF``Rt z_VCU2rx#AgY0u4!Hoz+iY_NW=_SwJ-LVNG>PpneeqS|FJIQBWkXKI71mh9itqu-!c zcq8%Cy_X}e5`bI~K`UGuBGs)=Wa6W)l_)c=ckP`=iVv@;UzJ50IgTpDqsZlz6$T>< zge`L=h<;P8{i2c}4p2CwRGS5c3o%TLK*|%F#B%8TOI#=froVwqzZ;zak`TGz^sGUV zax*Hzq-11ta`4oK7<{pzXJ$48mr2f!J0g)E2=9^!KmZ6N!{#vQFr-hP%AM;tPm8hn z%blRa%a6lrG;9P>GVmYjCO6()MMW)#GFFx1{M)^kTaES71>&hlZ=FBFX5$ScZ9iI9)!B%I0^uuzAhrI>11gQeZ=O>1TzivE>0Ucg{N;Ia( zx!|MQNv5G^Af508F)eDa3=YJo8^bezW#IbcCczm^0bw z#{d+GiP^Kxl%;JKuE2%0Nb?6iX|PP2pvt&w*DGqwd5BO%7vm4w91i z4i6*x{#ZnwO;{Ru6;Qz8=Fwi&ub7M#$KR;SdYeCI5kWiRXuZtnEKjw z=Nl9~HbLa~t;?v=vm}zO4Z{$_SLSAC`++vVV;~QQZi&Op0G6VlTQ%|MvVX@T1n}sG zx&3)BJLW3)##Qve1l-Y{T@%6}U^z#Gg=a?Y$;wtI7y z<3V*`G)#|-$tV+rHYsv#^bM6;ynleelL#VX+0fYyRC<19X8VpExX-I!)9xuJs$r`q zM>HwO);lK0#}}Q7EX}8{Jh_+C&+AX(J97nE7!3yfxO5syFS`&=UF<8K0Pd&KoFwg< zjWU+dZwJpx83G}=Y1O{4my+ck%*kgi6kPag{NqvTt(SOIGeS5HN#m1EEC`BFkyNMD z1L>-2)(XKVl4!voosYh2!LoXX)BaRPt5vk|&PgC}}EKC^~7EFmag#KY1Sp+(;(NgC%t_6659WQL*d*iQ{-^_fi-w)Ax z^fHwgGnJ9xsgoy9WIEaMTfB2S2U=WEG+!=>-&s{vY5|OJ33%)N%ok)y#_T0S&f}(E z>fcF~9wtjy(GMcowd{s6??aJD5{0(ySYs1m(5< z0#Q8)sQauTt{NNPD`uyyN`IUqtiVIukFcMm*ic|V?9R*kiZ7>~S1%}y zjM{vBDMHev&p4GJDEOG5g9F$5`NSU*Q@gsz7=K6HdlH@kF7>Bm+ zGtASy*#9LG5L+^xFK%npvPy&^*WMplf{opqNoe|7)yRL%&hj~1Nc>I6KMhsa%rm=QGDzR6=*{JI(-`w8?AQjQdmA+pGQ0oMU6R)3b@oU)zIdXVsS zexn9L@@;J1M~$!`NR+)T69FjB_;h-o$W*t3B>UE z!fTO(7Rq;|m3E}+aX^<+N-EYT;K{>XYM)Bpy?>9=ERDe49RbKD%NB(|X1CZkpCrA~ zOFXW`Y9|A;sEvAa>F{24Iyo2Vb2!|x6#I|!Id2#JCAzVPaXj?N7S6I#W<|JG^JW8Ao@Om5YMo)gH}euaOpX#71<3zl$q#p>lwqmUuKPty+T?l}gW@L2*c8W1}0d;uqK-Cu@r z3~2(=KLxdq$!4wI1JM|$3HVA+#gaO4I}2%1I^K_w{R^HO2D5a16jXE4TPFySg(6_6 zeD--#L&kPp-Ix|lBhE#a6<6#aiFh7{P7%I!xY3+0Pd+dKE}jY~EM zFs(IutS%iE=xKcv@iucA*Cfe*Mit0v5q!WvN42UnJsLx$l(D%ktLa6JsXcqPm_^YY%eSd3XXD z>@Af}rpW1lj`-f=?kPHE=D3K6t$Evfc1l-@?s{a;rLpVpZ9dmqa*S(o!Rh;Atbv!+c&orfy()b{yZS zRPh+eP;}lbFtEnd@YU!$JMd7yU^fxN(k2~4NOWk=)GoeHEMu~WH(dLxoI$&;b8M83 zcYE=r`n*nSwr>LO;idC-cG*-NkN&lEwSS5(|CrawJ9i(a7nna2JIW7=zw*wJr43FAn?F1x;T zA$9-_8Mx}8>bnIn>#*l+xiT%+_feb3KZIOqe)o~a#@Ws+t>VGS$&0fR67N^WfBr;7 ztwxobtg|UzKGHP2AOq5-C{YWlosxC_dsp22va}JvjwD&1EdiQ(v04ozAk@>P25n0E zVDQ`rd}ms;Z1)qS)$WFdf;fnH_Ies2RyiQpi5_z`Q9O|&mqOHpR2iu-1-sA2+EvWt z@?dBIQ4^p%|03^$uuXMnc~PR|+Q-V;qmDx$7I&Te925=rQ_oKW#6NVj%; zx`~xXb;~(#`@)^%hXM`8%a}v8a7KnmN+%&Hfa=U|Aps&^mY>pg2#~7F_KQ5|kQvw% z=s9pcg}i=8NyCMk+Sm`xEDN=Wb9B$Q{BA?mF82<<45^hcrm4%FL7)h$WYkB<}27(k<;2z6p)A|GMDAh;2o;x#FxDd|^~1hEBcSsy;iu z`CcegNAdG@Gr>K}gU}RS`(o8a`V;D^F~6!47PN0{Nrt1afEfd{PDM$1z>nXckP~s} z>SfiEZ1jU$b!T-?Mk;cNefJZh<&`D9#SVEL&@7H-@w)yJ*|S6S@U%q=50av(H3ROm z-XJYO>=YKHBm13CVjsw#h^pM}eZ@f5jg42wjoiC~wBK|2*Wb9KCj`iE`{st9%{Kkd z1UmAjzRlIa6S$DUO{U?EA-*>o}a@p_{cw~UNQS{ve@r?D5I#&JNtlBB;qON+-) z^2#6yy9Llqzx%+sBLsR)gAh0+sMeL{2=CcJh$M)Lin6nBT3}7KKjc+^)JdcxgJoja zdh_fGF~Z*>6Ux1uZkGMkO&ff7aJu*GfHs!>9O$1YI^svMQ^JP}4*fv4L)&;@!VU_G zv!IDixVW+Y_}9YUv|R82vf8-OpRUAch+KLayT|IR^oMN;PgS_X*u|Q@P3MOH$^EXW zDYKmrUd6FOu3CV4EYao@BDcGaK8HfNc9y39YKA7U1`y|MBN~UKd#^W5QI0vK75c(v z@1Igocv-w%Uk&wQR0k;M4~{Dm`HqYNV9+!iPU2+L&jg47ul8}Tu$_ffLzww9?<0Ip&BeoBbko1HAB5#uml2Hc$aKVx+qbl{t1^z9J z!kcc;nhAP024V3N?c(gm;$mXRg=8g?k>;RO!s7>l#3ULpOJ;r6TQ=DJkoTJ*ixuCH zBt&OqWQd&Rlq3Jsur@Ft%gs&tK@d?q zbtp2pW^sK)QV``rcKLt_!fG4!b?75mSP2!$ z@snc!PJm-SyKMD8Z@xj2u zXbQv;0j2nfkvV+Ba3p^Q`6G03tMuJ=JFgq z{0pjbk48}&k&?MU^692|kk?5L4bY{lgNqSq<}}qq)>lz58lX|}kvqS4`^%;1=CDeA z_>XqSmjWQG1?A^o#{ELWX~SGyMrP(2PTIEVZ+`btz7jGhd~>kWFjbc;x`D{$KcRgb zrQ}XKF1jeo5N0USWx#HM0RhmwuSf$YjD@9fA=8FmHsfQENxoi| zwlX;yL5RCY{qg)E8&T#wT~v3x!fi<`hJ%j>twf-k!C3HWfy}%Q5Bt8}=|3>^6wx2q zu1|o}L@Shu5Rp>lWcm$Sl_xB|9?#s<+TNMx9LRR8jm>zRp6(wI5a*|uzF|%&7w5rD z8~OVe3jH7hCD9Z*e3JIG0@=3srTyV4viVJ47Ufp!)G|8SNTfxXMWxe&k3-P}k8fkc z>nbB0mWlsF!~}}_-B&`#mo@XdXY1xbBeVV#q|7}c8Ct&7cE4x!zP$O<7{YGY2<7n9 z4iaVy)@7=X^ynQmiZ#r$Qn^!`)NWVO+?VZ#&f1sD=z}fenjLS+SRltKLn-UcMPQ`F z*>7$@vEBe2koKxKugV~Vzt<+vi)(*!PmFu;z`Tn3%YjHmP&bob*!ngxA?>(2;580< z4uB*867=-+=ubh)fP{V?Qzt^tYMx>{^X+y-Lc+N|vCfx7J)Cq+zmv`xRlEldpi^sd zex;p1BU3hR+Gh^%n0;Sy5bG2Dr{<8!R?Rn|tFU#hAb7^AW^~wjIBw`AUo;bCjuo~vuYGTNw8P|`&RiH` zfl?qA+}0ci4)|ZWJZ7ka@afZSVcR1NUkGD?e{e#~Z+4yad zirX{_ht)Cj5ZE2CGH|QbfKqYz9d(~h1_lEh5!M`D1>4VKNPdoB6uYFx&w!p4`C?jO z0|Nt=I^>|CBmBHtg&SAO*?yIDn;ao}v@ZzdOv|0P*oos>frQX^@~WF3tp~i|eDMB= z;a+3rZT#7jF)|=Xd+We^{wjY?4t9?JUUfWjhd9m>P z#)lqTTgCJTG{W{h1>id(5C)&QQ2F4^R0Xz;cI6y$c5Nfk(kC2#hsw9_-CM&px9x`% zC8DrD&DDz|$c?auk48ZW3iS6kC|g1q^Ic=3MIhvlmnJfj{G>i1svdi+b6bb~dhV|& zP?bdQ9eMVKPZ*V|ao0ViU32!TtrMrD8I~%;UH>+nkb+T-0X2DfW%p6{OJF z6a0Mp%JOo`&%*C#UWpzI#icJQ_zZuL;(fhyp^2($9h@n^x0&3L2??J-w!v~>+MgMq zF1hvhiYy-uLS<)9+>qY#%|&{a^o);Uwtk9~%_3(&jxuz zK)e#%hvMsSnt)0u84;0;0miosM1dV-Fxr{ytm5;B@8|eEn|@bcpGrwVIa`U3DK}OX zP*Jzt4*&-k9!OsRV2`;PaQL;loX1aEw>ro15+o>=zKVwVfA9TPx5_i%1+0vA`9h=Xp#*&0- z-=M>ZC#l<`iOpv3(g{~=g|;p%EoY8KluMpGPyvq@z31zHz1a1QwMC#Bn7-uhzMFvo zl05olWw52ZykAk4nNmiOid^czYJS%5ozCUK&+X4Ue2YjwKy}jol@pSTdrzc|`|?P8 zf^bsX=*8+B(FrxZBUfhSYZ_cP#qwB|Jk#1m_LGB3C+PXO&mX>&e$z((uY`f32I)TC zs)x^g+fKX^uskTkc71x2nE#LV^ad0Kih#AFRQ`a`KVvsDgCCEyT||_AxYU<8iDTJH z?FO4x^!r1Xa3_oVn*3S2X*2fx(>ZrVmS~x}osbKc16-xdH!Zs|1nS_jHOx6PBjflt z5yM6mR#K7p!U9hqG%nVA-#Wb9414giHDwFC1-uaW^8!N$Cs2}o;X0{{RO_7O+NopB zHZzh&LAu-%)KCb8Po=3+Ffg0F5ztb%x4B27>DASd{e;Bu$k0 z0Bi@tnOG1vc@abNAU=LFc;=!1t4}i~yDzzLt!$x{)Bk$7=l|mByW_d;_prZYm6;?N zp(Hc1l7xOFg+gS6goLssGLq4dku;17p{#@uWfg@)$cnPdD59*4JlD7T+~+*c(;w$` zpL4qT{l4GN=RL0XbzN_<+su)JWKkk0DJ&oDWwGK<_OqnuwwV7%`Qik$F${tzWRy@# zFg^iC@=Qk#-^yFia6vG{$f;_NT|sO@myOSX;EJXUM~6H5C&Pjct~%O+83CjS*EAjg zw=+V?%J%d3a3a_t!E86;;}338f3ew9f4_})_u*&xfme6vUoG0FutRYmVfWORZO^L< z>!_t6`ySJ4&ATSmVx(3M{u9ZZ{aZ(ZreEOFd!MZZ%tCw!e0N5wL5tI_NMT>EGImz)XZLQW=eg|M`s2NOt-N1OU#`g9@ zgAC|2Ej%@~hYhxx{rNRq=;<7GMXO!M0NX`>pFYDFBW{+b_fRbkCbd8gLWVOu0Z)Mu z4sR*EbO*4woqy^6)>MbMK=aR=RaPx&xh58dVFSm)s16z!+%S6#B2*W~!T?qnq{b`J zE@njB>XzH6RD7q?Aold55@P|rl7lhN8C>|aYrkHOyr?80d!(s_gC{=oeVd1M{Hp(k zyA*JOFf|Lg4$Q?Fi04~lkeZRLgD!l9Tm!??v!j_wae>q4{T=9z4plH4jR{fsG{xqB zsh0_5H>Y-T-!KwzWWC7`qy;QP(3~)zI0MG&eR5GJa>;5zEJ$_yn4ji*VVQkP&<6(H zi8=oak;?GoSO(g-#Wb#6?y2V6e|&hvx`!9zc;H%UWY8UwQm=lyiU7DSJ2nsRvj+`K^a8`;~Dt_*<*`g(eOU%p_xpu?~coCEO0 zviXj6J@z$g3ZDZxW+?|KVMm=eJ%sXZ@^V|FvQ!8*JU( z$RI=e{iW}ZvS&~%v9d-XvXfO_Q7=&Cfwy+|70#3t+{>r_mFp3fgqc7 zB^2xGcqY>(to=tT(H1l4Xsv2tI?mP{*G)m6YpfNTg^S{jLIfWxW7KRW_up$0{-1)W z;2o!TdrWNXeBHLhPtVUne}5Du3!)Sl^3CXDR}11ugA(}Y*!JP#l_Y)2V?RMz=02%+ z?`&A_czkR7)KKI1ysk*S{-N3}1zl_3N6wsQsf%ggtNq;l%bH&Iv6#auaq_1*1%>wP zfe%hf7$H3jh8O9(y)>U^BB28M1#O4NaWB792?l4Dup66Qu==Mn=Pmb6=i4^lA9reU z+#~cvGv$01LYpE)6h@4P*75^IR$~JM=KKa|HzuHJ^{BBALt5Uwh=Ayg+!b;i%>&K| zI|Xc@^Ptj)>0OpkT6J%>tVz9Tk6MVRfCw%Sh~B6zY8uhECgV+pTt}IG4xu@HGx6?f zRMaDdRxHTlQudY>t1h)UA-G|D!?y;Z7S#{_5{xFozt_!oANm?l{w3tiSdhd?mj69k zr~(5Jp$keqM_VBDm8md7g#cDBg<)!THsI&bYC&7@7?4=qUT2&9(cF4(_Zn%M@R=s7 z9m2aS?N!wJJjc7vV&)3z7|O|+*;&m{qO!(HS`Kz;KoiN<*6TZ$ST(oKrvYB+eCP}F zO_gt5sDAWwqro~8frc$FWE{z%K&~WYC$?YQCu)GbgkHYfTkIBm&@hAzLSP;q?cj~Z zat9gTR4XWCwC#ENY(pe1=jYNiC5~%3p|`2cu?s2>^)!b0@8adZo%|d}q6u1 zAD~>^Ws|`c3d1Y6ny8wquf|4xe;0q1d0JuP$8j-z{ipUZ>Br`89bZGTA?~uCK@`{l z$|SyT^zC~71CAJh0%i~w6SF==$GWN1`t_RDr?+C?yXhK?#hT3DaZ)*0|87%Q!@*=t zs-5IC6U8rN+jCP>BzG4|gE1Q7E$~15m}@7xsA5Ad$ZTwXeBkc>gKLx=whH^me!Rk( zV4pw=;r^PmdPl2f}Hp~MJI}FbMkZlI!m}`xkj1MM!-5ko9-Umlf@IEM7 z7@UFy&1%R%gj~mOkKo#|8RqfI%6|A6I3DWviZ=@{Y|!+p`ES(TnF4YxDaZSQvZoMd z>8^{3xj881bEv(&9)jY>TXTY)iEvehqK<`;F@^3v5V~tuukO7UxTXKOlzT<(mJyg$b#Ct0 z)KuQ@MW|j#H3f0U%&aE8T;S;SZS-^i2z#rEOZ)uI-;uX^DSLxx%5*p02) zdU$@M;}rPGk|n-#+r_)p(=rV+S`bRQ3s2W1ZpH*4`70H{LV^zn4tf`fzgw-KFIIoB}yWmr?4MxDdbd;9I7m!hR*WVCo?R|Srp3%JaH3G{vw z#QuTNQ5;+>bQ&eaz-MA>0pqGJW?Kq{$X)#p?fjUv%P}#`;+xRx zfC+ejl^;zhvd!l0Ohn~Nd@B;xyZ!6euf>^fbD${Vr!fqD{VKDeqP4XZTm?M0e}2?I z&&}pXv0*qS;-rC^PCBqvP-0=LB7OSj$iFBf2k4V~;bD1+JN+fh|&6cW!s zk-W^C4gDw9n5vxR1eSys#rbKYwX?d9)u*0w{PviI6mE;c5Kd zn<6VKi!pa5ZShXf=1hxS1qG`=eDDCNC!LKr!$8K~Ts3pCzOl(Ni|>yO)=~f#ocrFX z4^`0lVsK1Q#jsUS6P3}^V{NBV+J1Kh^+U^x?o$Hp)d$@S&;_r&w#J25p!sJ+sXZ@t z>)wM$32dC4MTmpQGc8rP#e36XHx>(t8Y)vSMJU(+nkSnD9=nm4`2PDNIK1F+baIl1 z?&cXLd zpl7HeP~}k?h;swTwt(0le)-SY*!91QFeq^=jBViCw-<<4m zET#6_lZr;`z0|}R#{Yau#pc1N!+26&6Q2;cbSbq+DpgfgWCA%(PxKLJXNtfH;gmGX zm4znC2M7H9-Ce6vScYr$qv!h(HSm)PESv&-eQx_HE+f*^T6j$1Hg?+ax+BM!g=`i&`jQrTWio zPeDBpQ>0W-e?W6Gd*Su?*|VK{aGaZA`T{i?M57E9+(cyn{COw33bXLomSVu-H`UQ$ zTHx;!L&lJGC4rv`hHD$kuxnxkxDv<=)4A#C_Ai5I z5mxWZo;qY{xhY^b5qJ+{BlPZI8xAM7j(Va|!mbyAF?xnXj5G(qT`N#N(qzcG(m zEeP#bqN1BMPNw~s96?hHDjGu7zIx0(0g>&;i!=Vu)2-4-V~>|yzh(30puu92PHT|1 zvU6RMnGsYj!aA|8KZg!1#S=t=EcUvA47U?U@&d|<$CAg2%;D^dhj1N*&C#QuOHPA3uHgi(yrB4 z)`C#}GKvvt0KEp>G=3qmBJ}?r26A-#s)h`-MI(c10uslH!uLdg7~r?c7Oz-M7U(l0 zQswrDy0-^wiwylp(Fkx_l!o)af5$C-!|?{t<6&!Si+TiKfux)*y#*y~u)lu?MsvU| zAjN|K4XaEM*PU8Y>rnL1E&cW|05 zi31F{0FN2Wvj7~8*Vb;JdLh`tkb$Sn^2euTX(EuZ9}`IUt$?ur=pCxBuB8hW5Ky2t z28ngF{c$&72OTD3s@z?ujBVJB@UjH;srsbK=y^FZ6)PpO0m&5+Fgqn7S3+CV3giH`xHbt!jNzB)PrC4*_FG1KMZBq3bfgfm+IZ0bHK74 z0Kz~>+EJtB=H{aKXwNu8AAsqt2VUjv8#nTCp4eA-*x6g~TSwDzq0s4)48w}@}6%w%WenZT!biVje z{4i(Et#2E{TQ>Gty#s8LL7Vr!rsdWvaHf22k8c`H-`d%qep~NYVNsi6*I13q*4F1^ z0_kTtZd%dmYd#1HyA~Lh#IRS5V`GJCW7hE53#&D%8Fu#jtn`2TDtu{Z=+n%nVdY0Q z@Lpd;lK%seKMK{amT>Hlfw%_G#I3~Ke#fK|DAzDswDdZ6PD(^1P=PARN$utuLXD9v zjat(N1}A`@x3Kc>IX~3bXEoisfNZ%RFloS&H(08(PhsE=ruaAP{=;H5l;CM;U(l2N z`uP(uLQF!!VrI-LxM?e)lHa|1_pV*~8z%EFU#9n@5aHcOK6AL(X#P=hDa>(i+x7_J z32d3g$IEkFLuK|7i}EmK_5O_suz5wmo`Thc)2at37tI1i)=P7PL5}`Vm=(=2wqq$Z zc2t0CXukbD-T_pC1{r52PM%SCfu03_5KW7UiWGM2*vo*oKZ3xAxP*Vu$B+xcT}Yv3 zm5)ndI}v!^;U2|z@7^`HurQUl{rK;nva&tbUgtl33Saf$@->0USvfg)%{t(x>IlE^ zopbh(LIc-38|n;SVK>%AP&T(1^X@pIv89K1%$qs zl^p2+0#@Fpa_^~Et*spKw1U&98$SL0gNud>gZ3!XF!T1(euac_eEu==PuK?6i-06q zmHVww9v?VRhtr~c@F2;M2kdEqj74s} z#p?kD6RXOMjM_elV;%1{mnrhH6r9+P-)UxU9)5>xpK%E&2}F~f z04{e@{YN2==KGC`LM2A*7(l$ui8u1n&0AIqY@YwBmN| zT+PY=w-MZthfZRL0(n}#d>J}kXl8*5z{DioAOr8kv*V4st1Esmcny#$U|x?1HCaVv zWrDfWV?!}13?c5_BMm?R7nM#|kW9I;wt9rl4+DA;%f=z%@e1H(#txKe#Cc3UL{2ddw|xtfHZWa8bU7eubcJ@iiM4PG%cHh#9` z4>uv#182=oe1-=%@Ja8SO8l)O`)6;V%HiMuj`+4^*4QhJWti}^X3gYG9 zkV8$4SsSkId>tVzfg?sPGD8>mG(R7H2oF-O+^17Wz(sZeZj50zQWnO*^un9uRUm-{ z%Z|^oC@)V`ob(-7C&x%Q(f#lG;)~h}97A6rE-s#|J3WGyi*=K-2>4PG5~b&USV9(s zAB;K)yOHNJhh^K_+ZD~f;1cw^PF9>zNZ>GTeE`zK1sqrMX*~B7-M#x*vL3>){nGw(=jOlyK*m3WeFmRD z^967aOvVQetZsZ#t$qV~zmKnOW+8vFfG9i<*3)IyM~&;~nJCz=1Lv>WDmFF=S&0`S85~mCT))%}W|I1V`mn9x*IRC>lW;tUaSYtyOJPGJ)i)I!_m z-{WVxI*5Hvb9IA^2@GnX_GA9QI!*9m>Pp7ag;z%^N0r6C&K? z{Q2{sQrg))q$JxrUr&1cSdzsUG8DC?cooQaKAutVDF$B&9Dg!o{$C$}i;2#30&xYx zfP$+#P%M-q;$ZkjI&nltHxH3ZvC*Nxi3R5i+!lA#;|7^bf~`njC~X_i0=>50>q~7l z4Gp=hj0_k!9(%>1L%{>1!@Z}aefjawVppjTN_rumSimg(u1ThZy$y0X(FEmTpj$e2 z&BBPqN71S>5HZxA(`E)y8>KwxL1_;kA~)P`-I7B3W8fMf1HpHO^k`{^K=v$vPBdwu zg@y73LIhtmx`X!+Hvx|PONq~3SU5lSl!?v;uM+s~Jq0IjorIuZ+>`u# z_9xGtJv*Lf{Td8T{NKq`R3mz*anRBy;GQ8d7>e<5bEm&&;gYt(dBle#ImiV@-RT(_ zc|}FS$yHCv7slXJ5{R5UzW3i!=S@Fq0_@uzI-#XSa#E8_m6A|sKEjwuNJ#J3BQ^@+ z4{SxgV>ph#iQ6-E(Ac;mHZ~R=CPNovGGq=V4%3Nm+eMDV0eolgyLcXJ=Bp7#Iml= z&eTVbHmqMCeYzO~X=mWeP4bD#)cmWT=7xU!F4`9;fDB3}1=H%4$A4Bj5CNn!XrwTMd?q-j?f&v|b@LhSLYXf=X31S&X zJxwJArlzLCk_0oa&Npu~7veL(0LzXLINT`V6v$TL%Du4f=mKK+apQ5=RH6gwIjxXvOJgy^*Sv%$AzJDrticqe)G#UKn zCBuj&-!YVm?WkGF*!}ODY^qH|CiXTuqp*ly3N=UZ`PXv!&1z+!{r65#uam8A1dG*ncW-&yk~1 z(*}#@7eK;9zn`mvUkZqWPL9coSN|(odwzmI6?q;g0D8o;KYH#xCrx5dGn-?Qq^`cI z;D$3EGgyX0{SYUUPx{<{p7bisB`W_DSiDf=gvau0c2-zCD>IX#t}X}wIw>BUMgUj1 z?uRXYV_-$v?{V!I036+?t5%(aHTYgO!O`zA`KtZUfT2=EmJ%JVpODzDZUzcYC z@Mr(Ds0elcJgT_#4`3C8xQg%bNS}{aPd~-b=1n-5!m|I*R~|ArJKnXvi>CA;(hezB zM){Avx$?9CK9+q8qyf;EIGsAR-y-8^7CznzY>N~W6e#N);5VnGt-XcyL@_@hha-wd zsHTbX6uEwp{ zSS{wBso#Eh%_GFWZ$l$P;Le&6*rYw9B*)5}ToYhoWF(Jn5Z4y2K14dcBw*=X5hW{Z z6hu>QX)85n=k0Q9-QYHn&&$S!tWP*m6MSR?i^~}mAJ09ejw`i{Q3=ikw{mXZ$16)p)-#b9A%0tARnK3trfbog|-|M_&R zxGpG+?AJ{S`$7AOF4TLMy!;I20+$p}_S z-sh;Q{^{}V4N6S^dlhmI40VLp;mrz?I~Q^PUwDoNv5~4;`1sM{S2xXq0Qd(#*!wrR z)(7Ylx-~Wmc=f@oDkHU$>JbKT)nIw6BM`l&)B&5m7J%cxSd5KALd2Mo zCF>GI7s{f;m*yo+^KCH?^?ucaf}k;WM~Y2R9r70Jvg_2jCg_RB|9)r3?dydME-FZ) zLWc=M=@P&zK1Elei-t&Y0)mAvSW^#YgHDt%$fav3IGeG`KJN5q0Lk72Cv)(mukRu{ zk`W=IiT*!vKIy9gB2YmDvUUI-Bt!C>H*WxO-o-fP@6QEbO`^~YFp`}{sY*I`!OdeW z1;9oE5b^z|BM{WDpHxYV8pbNRCYt~K*#0$zM8QSO;+sEwSSN^XYUhbpA#k6A=JeGJw^`e=5d3Q-6xi8sw2vm>e-ABWjVjB8c>?_q)l-!{+GpU^#eg{y{f0nO|5q z1Gpi{=6^ppm^%&sYbYI+lN~H3o12@xeRMD(&4(pE6+dr$aWHax zV93cvfj}Hf#zz?>UB7qk$Vb`lY-dMTMopMqM3IISM*LVEXZx|IXA?I~K^nkoCSFB0 zscqY~p_bz&pKe2A;{=#i-oLY+ls`ughCitD*(eyV0XWp`rEcvHz&`|Ury@T8_YmBC z`x-zOO~I(>y{mQjl-LtGSp{y=nDRG-6iE85&_8+mpu$0~;9l~nu5Ps;Jg2eLeC>s~ zuC6ZBVSOi0pZy!Qf9_anI`8Zw?+;uXc5ykc;%Xn1t=z?)@xIXN9Tm8GWA#3SN5 zp`y^fEwPuu$k0&do>C#0p9muP2z`e)P6;vLpEDmneq9guql^6kL=)S;WkhziP(9w> zI7Ns(@8g+L)M$e!DB9cF_L6Um^x`VMv4Ert6zxtu>4hBp618R=3^Ytl~xZ&G?Cg)1gC$aFVL(fwu+V=wg)j@Ni-z8^#l#FeA6M?VUp+0=M8zJZ`Br!gK&;d&%ZxVIDe5pwoA5}L|<=*?@Eg^t7%8+$Kn$T|p3!|W$ zpFq(g{ZLAqZ(=8AK=U(HN!!RGC(sQwA4>fu7N<+`+CbfOtZ9gXJ^cKMm_9e*x@|MM zr+Og(NA>TsAl*FY8{a~I2i-fun)}kco3gV%=F$L!HGk>WO^4#kwcb5K5RYRi>u?s< z!YrS({1=0g#9;fKBZ&j*f!O z6-2wB`>O#KojL_a4_GV)vazoTMV)f?>{$%TtN^Q@Jv)P11TVtg(Q)!l1js}08X8{L zll6YW0iubMd~ksikbMlf@48&o@2bck2pI`>w*37-S#9`Ka~F)oZYj3&EExaG1&FKI z1z!kns+HB8*FtSfToa~lRnlAtJnCxI_44K3&C%}?cA^Dxhra{LAZbo}K%@S$0PS>c*cYxvK>6Fw$+#TL<3pdKRU ziz-Zn(~efhv%CNJ2*<3hpdtj2$ZFqPm^?7`lDOW;HW@zcfN?_!qzmA<* z83%y$<_So^K`@WiPx%Ij_$J)8$Pe+{vfkUTmHNAL;rp z&r=K)Z)-F@b<8w%H;il)fJmP>+f~kWs07z^%mPBhdY%$r|p%>`7=)xam5M;t7pD#U8~hK!k5yh2qm9R>fNE z>&~*OeGWTTysu)lx4NtzP-g(G%V<`?lUl%RbOrqik|ZpOD&UX?7z&*(jQYL;T~F$r zK<%ce5z0nx8MWF?9oUF^h-msFaOdD+kn+~8w8pWU^@SD&4LmE8+RzoKEPj_QEd>)D zDc-BnY2)0nLbC?zJg7S4w~IDs*>1i5?R`f@pI_T|)$t)lzM$Y>DCAvTa}yK!8=pV8 zg;otr17GAD$T1f1xz>^u#&LK;@}46a`j;m5y-3b7Lv^AEug9UUF4e`VVFi7v4D znhgh;3GlTJhW@#x^H%`YQ|@c$FX(V|p1Q|%zjk309DXWb?^KEQfm z3hHMo0v;U6dV;gZIdkI%(Sx~3eCVybseFex=d}c$scmcwA(Z6V^9JkqQdlNZT8cL%skD}BFDXqrC?&oJ zwMF*r70fLCLp_YxC}_vAhFP<;A=6Oo(^=?w`ss}76dvRy@zveX5`qxYC0FvtBz`H@ zxJpQ5^HmIDdH`%KC$FWxUK<;65)!JK=?fNBXb$V1zOoqcH8S&gKi5W;{138J>jc4& zEX8P1Fa+Zqa)j)s_iJWGa{G3LojZS^GeJE42MS^OVyvyB!@Ju{!czD+CPRnGQl^P= z83ItlOvr-q1x?6FiDY@+r%wVIh(pm@scR$7t0!(uymnegs&g!8;_p_qmTkQn75#z8 z4F4nP6 z6n8n7Il*7AU%y6`hxz_TU=($4fdxPipm%&0S{HJ}P1oFr$c@r)C#Ck|$CC8)+UjbW zg-t{O?O%|F%l~HBCpqVwMvkZ&ET{5j-{UuvHTahQlu*AGr*4jtYy9fSEu{vYfmQw2 z8NJniyg`QTK_x%|#b7V)#~V+h11%3dJ+=KFBMG3hwp_Sh%rkhrqE2*n`q~*iq1Jtm zD|*JP#97SB43c?)b#Scd&U#h@gKil5q6C}oM-i#HH_wTK~;O# z>FM$p_H{=V8WbPC+O2uRdtZ$HFK|OJ(%s{u2(1FDkEV)6$jguV$I-2>k`wD zCJ^>fB}B4;sn8C_6!ZC*>aq=Y;vI8522xd9Btt1D0GP&XTklQqUD_?Xr$VOZkh~0i zZ*{1M^Y)8sO|vB-s7QdOll0y{TnIT6%_;P}!IcSW?3d5d6i-n_zDqcuYazz~L7dHx z<{`t9^LxjX*U$vbE|en!3bHDYmbR_#BA?vmqdkqv#C1hk;a?2Od_5tCIVl#t1 z1yIO{pP4sm2GO8?+9iK~;MZ?#Tp5C;C4byfqgqvDS441%v0`G7vfn)}&P@wqYz$rb zS=+X5#qbjIj+&Rj@|oQ=Fuz+osQk; zs!!563MmA`TmmsJcH*PQKV+2PdLQ8X2y@|z^l+4umgL7v$MU~WNhIrWZQS_LZo@e838_`Iv@G}gjW^sUO=Ag> z7}9l&p;(&=14Lh|LtjOD1fHHyrs{q4a_5lWSw` z7p|iRJIzlfrO`T>`NxS~}8fqzqkF^svXBgNLhV&J=FZ*F$&~7d-4mlPx;v=~z5!P?9@yxyM!tiGPQ}%0?=M}V!we8$P2Xj3 z9Z=j$@7`_S-$=tc@gX3r!4BK+lJyuKw>m!Ga#V5h5nqmDaFaqb5);|*8G^}0X( z(RX~#GL{Nyf=k>YV2YNppl!JEFt_2l6DB1g=#y#D*P01=!BQu-{Vq_!-G%|1Mi^Au&uqC!gDQ`mapy0!%p&Odjl~ntAWx0379}}pderRgu!2D>+ zok_oH2n5!mz<%wt2*HHe3VK1cXCiHXAJe*;R3*FQ5wBfGYF?MfAF2V9KuhR>dN!;q zB&DU<1Ph=MH9uOk95vd=+r)eZBdvy>2Ow_f0uavpL!xJvKu!Qksyk@a7OeZfaG6RkmME zftCPkj!Q^T-%njBK$cxsD` z?|svMg0Dl{!B!2is~SP9O#~4PwgKjw1->^kOZqr)J5PwJB(fS=jba7>zEwnP=4^z~ zY8qK~&gB~cIz@t|3O zm`KWX&R*s?ooADFXt83nR_oAf3hWHDgn|qQW~`8ZcZH&;bjYEdiCnp z+UCR9aV%Lc0TxngLeDEJqJ2sHzUSegdtqz=h4qcHO^wR|jt}^cA*M`dhqtrlG6b z0MXl&J5R?~fho}&E9{({xBm|IV0j3ty4#$cOdO(&A=Ymy)Z{)t7n3K(t8yhQ%yFhC zN@DPvPKaJWK)IpVIOcwo5ls4$ezpEgVup}EqpnN@2PoRP*h8J)TSgDAA9^A*)O$S! z4JZHfR#mB=%X@WalN)|Vn!zYwLf3cVfb>+)LOc*JSBoJjcB%*6DJv}n>dAVKh6rAT zqp4OCqV`M7Wl=!;j+i?Z|a;eUpIj^}#y5E~<)dE6Tk0n^^PkqMM3 z_@$LJ7Y1iQUlfnd47_GsI-{;0eCt*lAYVXfY!qDM6uPHsmz6Z&8X~vrt(1%m`{vJ{ zZ@##@?R$kofkg3@g%_^{e;z9;VyPt_eVooL$ZiN7Y)?Q3>O+mE3PbtX_-m%!r~+zT zN0G$PQtj;}Dg~l6$J4=Mzm|mSdS& zSyT1kC8_0jC#qgxY3NCYMcB?=SE3s1fIg}(a&bD|&QsvLhNXZj2VK&qoPnEwjpMo4 z4z>Nzx&TD$CT2of!yq*XZh>l-)h76F&`|lEkh*nH(C2L|l0Vv`yLZEX2*p?%Er>CE z9$FrTT|j9Q-M$Pg0E4UcRRg@vXlLGa>jr=Ph^i3XG_dVX{fWNJ9Wzd_)5n?RBhrs% zfn{uVM**kG)y2)j<7q)bwK#UN%|am9$90sPt$y4ciMfgJku&JZp)=xmDyyhiZ!*}( ze70jM^MDbzKeiD2dV6c^+tdSti7rK; z_KW4(Mn1)l@jJmPX)}$whmtJtWtU}PvtQ0DiHW70B23s#HE#5b(sa_>DR zz=l-lcM1XsgCps#BWoHPBN9v`Q1G{=is4%J_cz~|z>ph2Gwx}IxLcXvv18p}&O-xn z3Uy*rEg#LH_pK~DkgTu;arjsA)$qVb6C)2kHJ-XS{9EAhVe9AC!~OPm;Pdn1UEVbk zbVoMu_Q7UEJvC+`m=4!Eu=$nE2`$Y7$O8r$As!Pfq4i&2To1vt`;Bqj=5^~nAoA4N zLSfUaSc2K_PSe4Oi9eUhuNaC&T)9$78nhM9s&2~PtLfX%A;uN^!EFJS=c$$+1L3h> zsf~m_WP*^mb?a7KMvM%=`DzESs=K+wvCvHH7VZGdz8J45KYf|EYNGB&*);ts(bT(0pf4V4UQPcLs?$F zbE+R1r7lrm52mbfsWby(hLhszy~1ufe7B;&^d$+kFp>k4`8wH^7d~1m1jmP@dl9T> zYbbqwI`+INkM#RlKDhB>U*5bt;vL@(YYj6qGYbp807t-PT`yQTuWe<&+GXbzkBE*4 z(^qlY+WIG?Ag_Kf2JR(4DKQulV?Mcj0&3k7a~4rUK3=5I`Y%H|+^mPa(}dQ-WJmZJ zGJ7ap4#hLrVUBi**TFddC7xB}=sAuf(|hX!o* zK-d&oCiAJWv}O}4n)J~)nw~f{6}$avVxRQS@j)5XFs`KKd^011e9ZJrT;LLHE7cTY zN39lNvsu)%ZEWZZreY-wSKK14mZ#4}r*B$SkcYIn> zz6{6$fEZAo&T5TcLf2ng_nR02a?1FY9K113Sly8NB7#Vj%jaZ#6%<8$g&pSM2^=|N z$OqVb1u#I)iw{|5=gyAiX8l3c8@pV4Lx2enP&>|_0ATO_4U;a z^-NdkNfqI^6|OnV_-H3uE!6YgaW#Ye)mt+Rm0Wt1$Lf3h=PZ^v2O%YyA4CC`tcQ3A zx5%ljG@EJ;ew|s>^z2yYGp93W;Eo;fXv7g%IH_H)RaZ!Oyl0U|+Pon&9mOT@_TAre zf|XoN&dCUqRi^%000KGb=~gmvttJ4sUw+dudqDEtAaVm;YCSm}I3gV5K;KJ6Tw*AK z(m`KvgB>t^JEc`!2o&PfMnhuv>TGX(n7ed|WU+$1ldi6N9M(y_1rF<0uhWaE=scqk zZbRGUe=NfgJG%52i@Z8Lfz;&W9jhw^>Np!JWYUq#7Avh zyY^y~N^1&_rNFA?0vPT-EHCfr=$OMS#5T?BalktLOt6MnH_C+A>VNF+PTb}32D7t( zgGBf$vKo35EOcKwPKT?6@HBsS$@*A`~S)2E?#0=yH-kH#GJaA!0(Ga6~z zD`7g3bV@QTms1SIutuuxWn|Ye%Xr~Zz+AX#)0jVIXNN0objGfXp52|`Awpif1YP4& z)7!UVaNTNX$YUhzeg9^k)i=C@o_y%z-Zau=o0M5%JtkCN3FDFQ+^p<$Pz&P;3}%g4azyN3N90tyMtGxAqJD7bn1c6&#M`Vl!% zfVwg=Hdx^Y0Zm`53ihDXh)JZAl3Js9y#IUghjk|7`;I`0&W3mMuIcwGVtT5krBkK zj^J8=jr6(V!Ua!HPyA&({>38rRLw&32i~#zv&Iy+4_ty8d=Ihq+f|+vTh0(l3XlFncy{e(>-iX(QYU6G~qG9ibG#INuQaIxgGq z$bXb%X{UhvDnc^Ej1 z^}kaf$w2Ww3D>pXn3`gmb+yCS#|LXIN0xsKTmGY5tbpH_&`^AH4E5s>Vh}GZib#tch=;Z&~LRTexQ+T7e8fZdA_96pNqRoM*zz&Q93` z#_NH^oELj=X+g3cfiB`>R?-Sx%)sic2YOdYdB^vt=l1oY_t@YooFx_d zVS6JMOAPs2AONyAHx2UdIXlJ?(482tkl|nP2O}HEh)|e?Py{2T1h|I{93CA(0q3~c z2z%sTyx2{tt*IHt&7QB&*uVcYuK_JJRpWCZpx=>femwZW@Y3pg$0u3LZUGkL;c27qq7YEEU6?!zW zXEBmT5SN>;;>Dv!!yu~0`;CEn{5mlg9T77#^YrMxy1F{<%ez2vN4<)s2zWz;hS)_l zJxj|Rw-{k1CGK;|W8XJ-T`mITwgl!3y5`dpVQn8j{>H#8beuadQZ3AoEsT!fQZV=# zc@Xmbe^`q0NLjT)qpoLSEefwUMzDCe%BMu@SMLSlbmVJ0%S|-(c z%fd>Q={dFOT{7S(7T3)Py}bb4!rj?@>yUN1+cG9iO2J=lYipA?xN!;vlAg%)+}yUy zlAEILBqmC6UNeH)u=m2)&_Fh;CfDNY8sZCMaqP3>w4~2230p6r@Gcq+al)Lc951!fU*8RHl z_1%L5HO_Y;%%nh{srv~q5kqwllEAXE$v24d=W3#n58uvtZTHU~+A(~!cvP^sNbt)9 zsD$dzd*l<4LeN;W65H2Z)hO=Sdi4156|z@??GE>Yc%`$B`}WapG!1vjyY8z>igLhm zKwR$wqZiJqAx#K?sJZ;`#>Wc$83e(485?sPiwF2Z0uvzp=I&e7luY90R+Y6hj&h~3 z&vJ7|aDhDD4^*>-TrVsv+_W-+X>XEoff<+;n!4#A!~#UiMWSo4n;U5kHhZx~P>7A8 z+Is4=@L>mm^=h#?4>iwk*aL1n9Ez1eN(6~s=u5D}e zE4wkWT1`9f^OTTSn7HBFFYZ8I+1U{+y+ZU5>;c#MT&7w@7aJcB{bmpL5o8w>6=73t z^ZW-QkcAq=WrB&TWV*#AHHjG$#C1}zx6~NSz+~|E;^I@2Y*INK`UBeGLbxm}EG*<4 zd&cs_6kBED+K#=@a)QmbngF~f3h*aCf9&&%ITYJ!Ws&M~cf}I+YJ|smq+azzZh=@I;tlkX(?bAOS z*q8;P`Xo;dE$uEPC2)#AHaG8prU{DY)=5*i+RpHy9Pt2ym$W!VFv4R(q=&l{ERa!I zpZ}EJeNXe^3Q~{bDL`Saayw*(I9VuHgx7yKendPn9I6#>=%Ns2!BCjwMi|Z?k2#V1 z#MAaJ*75pW##d5>u`M1T=JTGet|KU_pFaHs)(W^YALhe;X~7XD{QE)siXp^y0_0J_}fJ8wz#jAvpL;Fyi5% z*aJ^PtAo$C2S)U$#CGiXjr)g<#zDtTEiC-;MxZ}{w4wj=*?s#$QD&Ew`JX*2zwtwL zzbhuV&}pR&4nna{M?)hJ0-ctYKN~lY2&sD)wQK}}d(>A8S%|}x=R5@_<5*pXrECA} zrfsm2_FQR#wAZ$A2CkTesoqC?e11z+ECsPW_g_>fZ-JEP2M9d1jF8XPemR1|8Qgu` z+CFF=a4=E4V%HU-Itdwpg75RT7S-0S z2e@%4PG+AJk33(FY~lW=15YsMVBY?g)$(n7J7;45hw(o6Tz!YQHSBO(*cKZK3I=JMtV|lZI zD=@v^7??qQ^Ps%v<3}F=T#!E;s83)ato#PJXr*iTiKOMi^O34I&QPij6vqDo@%3Df z54|8(cov=R*gn2NZ`D?$<$$!E+jho*tKKri2x*o0QV@#wi0tgFEC3C>Dyn}lkWGJr z?zx)$4+*R}{)XE2fO2S6zoC)Q=)55aGV`}Q?+-r7%iDn=$ejMKnMac!B9t5imQNic z)gnoIgJX5<&_`D_Gnh!pSqq(xY-$~%K(jIK5R59oBomSmGPGlM!- zsO4Tlf_6Ku0(3uIsyIszktFl=KNQ(uj8H{YJAZwGEJG1Ent*_ErfIa zVVVEbR0==2JQ*|UsaNFRPM~>bxsGOx?p#e!e`du&Bor#J%AUHs50tlxqU`^FgYBvx{MagAJOM86CUm9&fgbE84us3zQI8c5N8pMLarZ7Gph(FTkE-vZ*CUJ z9)_PYD%DR){^(SFBnnRs7)T5B7Pi8pU|3p0!2fdfaqpl| z=x>C!ZF@E;%c~l|E9Hk4{!;!b!E?C)OS}&IqIdvP81^4O1_pjM@8`$b#sMBWTj?b9 z^O%vNcfk}6d_+_eoZQ@9fOI>MSpgqfd?K}|78{=_mcBD z7*=EK2y6puX;T{C%`6~yT-`6LU!!vZEAA`RbD@arcy&Foq-0lJ8_9W@+RalSyuY*m zMfJh7eCC|t13MpHxFWV^k3T{`d2)R89wMk?xfg!syZ7(W06yHL8gBL&3(p8j?d#W} zHG>YyyRjXkG;sgxqG~Xcre3#TUIB*%%L^&a$GP`q%_cJGq954W4&40~<;Wq4Ryh<$w3QC-~#jm^m2sbG|uNMIk(%e9&zY`bni zmW0502L3o;l;%PdBlnl^-%|`kbdaMxEG!HM7nh6ViQ>?Led?Uw9-NSHOie8?Bjf&~ z6I(ZA!H=iIJM5)ZpM;=t{F6`@MNr=Sb^t>0H&E(wm5BavFRvcF zLhXVbsh8DoVPlxY}_p@-m(&PI>Qhl(&i-$006 z_pV}eYJ%Xw?d#*3;3QK(a*3>%n7(*o>~Dt>2o3pP;@}9fE7V&KPaPhC<2FeFD7{B= z=;*eyf22NVXJ?zOrB0IEe&?EYBgk$5Ci-vD?P#{-0SOfqbUUhx@$cV9CVjMOK<4b2 z=(PQsef!**5Iso8%{ueC&!sr8X1ToOPW^$TFr5=0%?TDRxvG23&=QhPA+Iieg@ZV( z0k}q@qrdM#cYmDw=^q3E7Ct3gXXk*vN~&|`&OKii0cdSzpcjl`V4W=`xd|>|y}kS! z4x;uhH53Cv(T>26A@$z3Z83uqal5a5HJyZ5gA^(RMoVXl=fFt~CWUl~X-<$0F#oXz zkcI4Ti4!%Nyj50qo$BJjl-nSY93#K1^7T}-Y7nyL>dKGe;UH<66xcVTUDS<Yp-9eUaKPwI3!GB=(VVYc%Og1}?0D1n1)7BJ?bz>j?_AyL)_#va+(g0>lgD)}P6R z@F9-W8Ze>T>cs(>%-0c?&5rpAYpQ{`dOWSv6s@P_sPJz};O#0YD$32hh5bw6lG6c8 zx;m2`_Nlw0Su`(4?d2zb1-hr+^WP-R+UmFYyTHMSbo}y=dYYH#^MUV0#Lh9}S6|#Q zWP{bk#tLBi3E8jdn9R`K{Pt&_`n5Pek6Lf7peIa|6y`l#25LJ?JWgSRsC|00^CChe z_?sRczd*+8x?kp6@(G$IH&ov9c7egcRoYLhCXGqo8Z0iHypVn*$hE}$?h4Bb0Yc4# zq>4#f!Pdn5J&$=rFhGI9(4veH+mk2v-3IoKOLBObj=@rtM(zK!_uX$f_u>0@A{vym zOP+*Ei#AQEh_*^Qqb;Pp3k|doB{Vhfb}7<&Nc&MyNxOtn-D&UfJ#XLR^H+R6_i^|^ z+#T=ndR?#UI~@+CkffJ@=6_}QPO z^>`Em$WZ2>ida~bIj8xb-+ztVJg3DAMmFgHaI=ncLGW^+(Obp#0!s?{gYUDXIFDy6 zMLuBdIik3v$;F-s;UTeF`VA!pZ+wIlBTnNN9=u(Zy8lcqD=4#IbnQxpRLs|@4`M## zW}vr#>2S+bRV4P>kFw(KNu+fTZ7<*5L6D#(bId5gkHBE=hO9Cm_(`rr=-DHJrJEql zX=~XXi>6)hn<{+!tqgl6;^ zekN$Y*N|>GO7-gOiFB0W96W=Od23iAS)!n(p+zhb3z~^pb>feC`y?vu%WcOANhkio z>&GJg!DN9n?I%rxg8@j|PuA^%Vcf_L_$ZXFoF%(=@Ctwgd6^9ZIdaI!s&s2w-oQbC| z>|_Iho};DnXa@ zTA_0QG_caKLxRpq=}8lR)cuZ735^Ua3pwW>XI)y+tikl2k)K#&9Al4;3kXn)YQ%{J zzpE-NBu}ncM`ealF+MPzySqy_Io&40@v^$xp3r?`8Ga&`9ij^|0M4-+OnUE1_^pIbR=2Y5Qc$hP9Ay+zQbyKtHnPIL%}#s zx#1bUFzAFL*7MXMMt1Krj~lH#=P9s8)eVq5UtedJNTITl$i77$@&#ZE;s)rv0WW9N zdz2Oz^Ip7*nA1JMI!l)PPCqTs;ZIfTHmTHYr*X3qJ`*D&ax5&f zMQLoVr+0?|(?+?V&FA4}hs4>*sJ^dn9bVQv6PPs1!?24i%LK)21I3?A!3_opZ(Z2s za9(uWPH@s0o}0t6Up``)zNU0AYf4GW~N*nr z*d*+9L(e#5x8=8J9+O?k_wMZi0vqZwgQ69vB5DB_090V%;Mjr}5vnMpktm{3hlAr6 zk4&;I;80r9US%{Ngx6j0FKDE~GNYCrC@NqH@CGHOyf8s_1sEjM>Y4 zyuV?h1WPOO)zK(=d3$3z+-$tZfPk>@r-lYLaT^T)^iIQb5W1r~fb1t&Ia&VWFN89( zK{=2)u-73I1IXt@-W{u}Fq7RxBF>S2b3sir`^`>sLU?k2Bk$0Ue*1KvO^> z-PLa4(M)lx0@*3KxOf8l6nQLe2D6~|BQ?d^G~|7LH|#69Xa0Oo4&$buFYLg#G6%bu;{($Zv89vFdJ7F>FdjL>9x^ksZ})|&uo90Q=} zpg`2U2>cZ1A#!rGN5FA^(r8t+@YR%D%^klQqPExd(b=Hl4V>{SP|QQX4yQ0oG?S5) z#h`ea?b}FScy=W}ct8Wop;$wD3?>Gib!@-ZN7!sPI4N@FVKT|he2sl9VXB{2??*;T zJrNs61&O?W7Y|~u@B6IFC?ipbpv{4@o9A$=+Y2&wHwZSdS zi;jzqzF=qwXFoo;u3+^?9#HfLd$mKL#!Ac>t`5qXz-cs&kp1FtJ80%&(ZhQS2p%$1 zNHHuqCCL=)fPjF$&fdb%yV4ti*E;rVYiXrxTR}xii%}N1JS5*jnC6$6i3nL15upia z0euBBMvJl$Bate(*s=KU-nsKSlmIlW0^OY+gG>{DD~K;-CfrePLD&LL7t#+F78Yaz zZ1@AG4`9}k1HOP1K_j@?SZ`C@Es<5g&dn_n-I#gp67-gKl()kF$~$@nuc>7*CAEr^ggx=id9M;OykUP0}7dgsbl zv>gB^L176edkE~3UMgt=W7FccOtKI$*MD!1)kR&Il>Z_ z_2%}|bJnOHC1@v=OtjMU>|d%JkJHFJ3oi-rzpQvh#|H+Z7z?>3341t@hiP@xac@H}Y$S=!+> zUye6wR$}ZLq@Dls`>7+!>z6CccyOa9R)WE z@Cn8_xWWUk`6Qhxzqo7rHWTw|Xo?~PHTZRQSb!1E@bU9Bl*~*>v~WU+K%IVL3F%OPYW#B^7m{wpgU-GFG8D- zzB$|Y7iTC}y30NC>&~G$3FHTSB95svA5h-w?#sxZ*8f3C#+LFomwG~!W zd^Uh)09v!>8ayr{qIHN-S9^M_ukjQoGF)GUYS^^r49~5AMp?k=WdOPWHe#DmH|i`T zZ3tlOQ%eqFD$MNc)K@nM%K@bR&j!5>y*n?B%5M3{GJn(m=fZ8x9sJDy997C9T%btb zrn2KWb517S^@QuCqYWO%`CML$*{H`HEAmPRdubkR#cCREIffZLD_Mo~y?^bVbGd3GZqV=K-5T#<2a7mY#X~*>3)4 zGwTZ_bGDfl8Sbd7Pwe{$dSz?V{gH}81kH?gk`|5o`M7%?2Z=xrH(NiPd8KRK)oThT z?&J~N)bg^CN^RaFt1>e(d_OYtR{p5Ng@pu3O)L?nsc_>Mu;+ujw6(P(IF*$3jt(9B zG&I9PDWjBx=d>e5&8xTz(N+aR*1yZygz2sjT%+^r=k3j5VgEdx{{fggFJ5q-ZlW;FHN@2=3x2>8LX*PN^WKY16@8@xXR`t+ z-w0-&R8Y9F^yufmLAJGFwJ96lYim`_yL|le7OljxCRS%>#aIl2{c@xxD-K)^uuK@$ z_8%+mb_{Sa8t|7h&NW2FbW08o$J&^C|9-#CbuU?hb$)Kw1R7n-KWwC47#QG4Vm?Uh zc07`@&HDp;fRcB3FT?A~|_^ zw^+Hr&sWRLLfi(OWXyzEFdPtF!(nFm$-qsgx&zY|8X4{H^4>(xbUJkY&i+(YURL(o z@h(m0;ZP>s-To}6UTaU%ly7wy>`!n?y*d`fEiyUmCiwf@G>zmEHltw@BEI`C8`pAy!ytu_2J>m zYv;pi50*Z**2Y5us-dMe$4xwsWMNchWyP2K=s#x6YuAdq`vwO^W1HQ>6}Y3qQapWq zag_ZTKgc)i$*6fGTCNpVuZ{x!wAr1+N*5P7ZgjjGt(^V+x#y;SI%kFMZxi%5P<1{l zaDG52&D)0c9xMMAPZCVbKA#xY=uxlc^SrrYEB z!m44-JK>X!sxF+clZBd>R~@BHMPd(9T68Ujd?)o<>oV2FZ05~OP4!SLImrk$7YQ}P z9r2l+tugrVi|=%Kou2VNdqOjIS6VCU!@*;%b0{-!24wL+z#M4tKk`N0jxeySxv%t)^i%=8d@7GtmtH1S4bT7Qh4!6fAMgkMSjh1nwcALU7eG&6k^eA zcU|15Ogwt^lIZB7-q9rb$Eo_K%&|Le#3s^F-GyOb^j4wQP`63{j;4!oCX?Y zg?>vfHI5uFbWX0Sb)K5aBNAJ3kI^xaAA)F^>bAAxIqF$~3TL0`?SQ>Qp^;n}G1g$A z&8E2Q-8%=5C~i8c;_e*c(HA}6P4$^-=WLyW;{)eb`i{@t%(8zD^bgmN^^zu--r5)= zKS@>kvjVG0L}Klg&w1HRb8^i^3*MC`xut)SX>vBl&KC7oiZETme`GLcR|@nygy+uV zbxzXLopNZ%*3kO+V3=vw1=IDI=Clpk-jiu}pdS#6qYJGe%^$5ZxKgDkQSq@Ti{#bS z-EEwCgVuB_rrEkY<3CTiJ!_kE3iBV`4J8v>!%}wjzrC3KbX&G!hW&HA8(bmx;Sa|! z#Thnyvece^OSXwl=miW4Eb>{yYV&e1u$NgC*v{qB*k9=jty6N88Ftkg z8Tw9QaZIXUM@I3nMq|#o0$55eVT~8rwUxW|eZ;knaYxmp$Ol~*e&bfUSzDz)pzfWC zbA{Mb*@^Q@PG7=oQJcjG`|(%{B1*U8uXx}4=EEIMGIFp&h)}~)s5&%6wdnTY@AW7O z7?_zF8k8V6cUz2W9kn(F7j1QNV9b3%U%`6oL-X}mnz{umBlWLtm3}4XbNpL3HvBv< z%7)dwzx2@BSeNOtUzK3ZgV0s=n%~v$^0JBJay`^D*D@7uJh!=c+kne<8z<$PT8{rn z@2z6nzKH;Lqr6@DauHELVa)U#GwWjW&)K+~qaCTJhm18g-*a{vDoC=?VKGixdgh)# zna4NrZclepbf{PGZ0JK%YvUuY9cca9UZmSUw<&i#I{B9R>4g~&TwLWOV(h0mvl*r} znbM#$vhkl^?Z0?k>O}#2uKjcO9foHboN5BaxuXV$We&6&WKOL9Y&rI~rU2-omaqDUNxM1}y@WR&XiRBeGlVFRB zf3EjxjfpdVNKdy3Rm&*#eJ>1L@E=esOs=BVv^=c11NVj)? zYFsNji4m-=qc{KpUuEmuWgm!U-F(#T$f8_1{p;;|V9h^Rrpcp!7fNiiwShtTxooN} z-y4%xH{^%=Qi#Np(zGib*`^yEWvPgg4VoxlY>Mv-Hrn3ldEfeV^0GX;zkI>tYMn%ksg}~4CcWB5nFAe zmu)L8LT?IFeZ71SynIoObem%1@|@16R9uGynA>Q59_gtX7|5Skx#4o9=Vp!rq;$AS z+ajC^tYN`o(T64)-?v7WUFmw3{&S^`xFM}FW9d`%tj|#j{s5+1OjUzh3d8sPs)R(3 zS0MH*j}(}PvrCl+tH`X=Da`qfsq@r1M4ce*weL#wwlV9G{C++3GwGsxr^#lvju=Z2 zQKOxDDE^@yMP-4dK7&REOk1CfHmIibHf zWb{>omDb}9OmA_UFSKLNYj3;4Lmbfi*XIb{&*cLbh%G9%uS>Z4kp<~d+jTw>QER>F{F>&xSE@S|+s7G9 zg13*Zm^xKNeY|&K^YqQ=oEcB9^Nn<@gzE^@tcxep9BA98%rjC;GKOxws9B3k2yqGf zP&`j6RT!S0OyQS1J(c{-D42dGcGEE@gqFhanOhJsZN+!fB=~%QYYJn2Bb#FI&2x>E z>t~W#4W>0-GNruKaq8H7w4S!rmUphS^M)e7Rf0A$ zz)GiM*cKM~r>`vd-tJ85T;luBlK$onMjtK3Zl{yYMNW&+0&CpVVeb|<-&#iGJ*K7Y z*BWQKpEyJc?p2xeUCNi#;SnBRXR7wDDVKR0yjPSdbWegjClAe=g@!Y9(~RVs+#`yc zywyotu34|uX&gi9PnDW~c|-c!faGkbc!J}E?6G(QvvTF(Pgiml1r`c_B*&lZj@;?P z6Mkq$|C9Nr*@QQOw-U56%BOUyO-z_;b~?HTTRct7+E@6GkV}>%~)c!UfjcqvY=q*hJw{U3R*DY+KX>mDz)47%~9 zMcE)V#X`}X9&{pyV#!zkQ1AN0Wa@;0$GH3G?`g-CpZD1?x+r=|!FMasgCZ$Zc`AC; z%Qvlk^LLc{&wFS5ZvJs)I19_tfO}79E-A`c7Ik!&EcuGgy^joOf2Tu9AW*Ak5%$~` z4}l$%-%`K;+5MI9I2w(10^wl-dY}0Dd@9+DlaR^>7AwfF@&bSDX@BF3_?*@LYnZ0w qtp^b}f$-)3zWRTU>3`#r*rKoKW#HPpLb4)n19ep`l|1E3cm5wr{X_@= literal 0 HcmV?d00001 diff --git a/tools/fedcalls/fedcalls.cabal b/tools/fedcalls/fedcalls.cabal new file mode 100644 index 0000000000..2e42d6f9bb --- /dev/null +++ b/tools/fedcalls/fedcalls.cabal @@ -0,0 +1,74 @@ +cabal-version: 1.12 +name: fedcalls +version: 1.0.0 +synopsis: + Generate a dot file from swagger docs representing calls to federated instances. + +category: Network +author: Wire Swiss GmbH +maintainer: Wire Swiss GmbH +copyright: (c) 2020 Wire Swiss GmbH +license: AGPL-3 +build-type: Simple + +executable fedcalls + main-is: Main.hs + hs-source-dirs: src + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -funbox-strict-fields -threaded -with-rtsopts=-N -with-rtsopts=-T + -rtsopts + + build-depends: + aeson + , base + , containers + , imports + , insert-ordered-containers + , language-dot + , swagger2 + , text + , wire-api + + default-language: Haskell2010 diff --git a/tools/fedcalls/src/Main.hs b/tools/fedcalls/src/Main.hs new file mode 100644 index 0000000000..7a717e75ef --- /dev/null +++ b/tools/fedcalls/src/Main.hs @@ -0,0 +1,220 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Main + ( main, + ) +where + +import Control.Exception (assert) +import Data.Aeson as A +import qualified Data.Aeson.Types as A +import qualified Data.HashMap.Strict.InsOrd as HM +import qualified Data.Map as M +import Data.Swagger + ( PathItem, + Swagger, + _operationExtensions, + _pathItemDelete, + _pathItemGet, + _pathItemHead, + _pathItemOptions, + _pathItemPatch, + _pathItemPost, + _pathItemPut, + _swaggerPaths, + ) +import Imports +import Language.Dot as D +import qualified Wire.API.Routes.Internal.Brig as BrigIRoutes +import qualified Wire.API.Routes.Public.Brig as BrigRoutes +import qualified Wire.API.Routes.Public.Cannon as CannonRoutes +import qualified Wire.API.Routes.Public.Cargohold as CargoholdRoutes +import qualified Wire.API.Routes.Public.Galley as GalleyRoutes +import qualified Wire.API.Routes.Public.Gundeck as GundeckRoutes +import qualified Wire.API.Routes.Public.Proxy as ProxyRoutes +-- import qualified Wire.API.Routes.Internal.Cannon as CannonIRoutes +-- import qualified Wire.API.Routes.Internal.Cargohold as CargoholdIRoutes +-- import qualified Wire.API.Routes.Internal.LegalHold as LegalHoldIRoutes +import qualified Wire.API.Routes.Public.Spar as SparRoutes + +------------------------------ + +main :: IO () +main = do + writeFile "wire-fedcalls.dot" . D.renderDot . mkDotGraph $ calls + writeFile "wire-fedcalls.csv" . toCsv $ calls + +calls :: [MakesCallTo] +calls = assert (calls' == nub calls') calls' + where + calls' = mconcat $ parse <$> swaggers + +swaggers :: [Swagger] +swaggers = + [ -- TODO: introduce allSwaggerDocs in wire-api that collects these for all + -- services, use that in /services/brig/src/Brig/API/Public.hs instead of + -- doing it by hand. + + BrigRoutes.brigSwagger, -- TODO: s/brigSwagger/swaggerDoc/ like everybody else! + CannonRoutes.swaggerDoc, + CargoholdRoutes.swaggerDoc, + GalleyRoutes.swaggerDoc, + GundeckRoutes.swaggerDoc, + ProxyRoutes.swaggerDoc, + SparRoutes.swaggerDoc, + -- TODO: collect all internal apis somewhere else (brig?), and expose them + -- via an internal swagger api end-point. + + BrigIRoutes.swaggerDoc + -- CannonIRoutes.swaggerDoc, + -- CargoholdIRoutes.swaggerDoc, + -- LegalHoldIRoutes.swaggerDoc + ] + +------------------------------ + +data MakesCallTo = MakesCallTo + { -- who is calling? + sourcePath :: String, + sourceMethod :: String, + -- where does the call go? + targetComp :: String, + targetName :: String + } + deriving (Eq, Show) + +------------------------------ + +parse :: Swagger -> [MakesCallTo] +parse = + mconcat + . fmap parseOperationExtensions + . mconcat + . fmap flattenPathItems + . HM.toList + . _swaggerPaths + +-- | extract path, method, and operation extensions +flattenPathItems :: (FilePath, PathItem) -> [((FilePath, String), HM.InsOrdHashMap Text Value)] +flattenPathItems (path, item) = + filter ((/= mempty) . snd) $ + catMaybes + [ ((path, "get"),) . _operationExtensions <$> _pathItemGet item, + ((path, "put"),) . _operationExtensions <$> _pathItemPut item, + ((path, "post"),) . _operationExtensions <$> _pathItemPost item, + ((path, "delete"),) . _operationExtensions <$> _pathItemDelete item, + ((path, "options"),) . _operationExtensions <$> _pathItemOptions item, + ((path, "head"),) . _operationExtensions <$> _pathItemHead item, + ((path, "patch"),) . _operationExtensions <$> _pathItemPatch item + ] + +parseOperationExtensions :: ((FilePath, String), HM.InsOrdHashMap Text Value) -> [MakesCallTo] +parseOperationExtensions ((path, method), hm) = uncurry (MakesCallTo path method) <$> findCallsFedInfo hm + +findCallsFedInfo :: HM.InsOrdHashMap Text Value -> [(String, String)] +findCallsFedInfo hm = case A.parse parseJSON <$> HM.lookup "wire-makes-federated-call-to" hm of + Just (A.Success (fedcalls :: [(String, String)])) -> fedcalls + Just bad -> error $ "invalid extension `wire-makes-federated-call-to`: expected `[(comp, name), ...]`, got " <> show bad + Nothing -> [] + +------------------------------ + +-- | (this function can be simplified by tossing the serial numbers for nodes, but they might +-- be useful for fine-tuning the output or rendering later.) +-- +-- the layout isn't very useful on realistic data sets. maybe we can tweak it with +-- [layers](https://www.graphviz.org/docs/attr-types/layerRange/)? +mkDotGraph :: [MakesCallTo] -> D.Graph +mkDotGraph inbound = Graph StrictGraph DirectedGraph Nothing (mods <> nodes <> edges) + where + mods = + [ AttributeStatement GraphAttributeStatement [AttributeSetValue (NameId "rankdir") (NameId "LR")], + AttributeStatement NodeAttributeStatement [AttributeSetValue (NameId "shape") (NameId "rectangle")], + AttributeStatement EdgeAttributeStatement [AttributeSetValue (NameId "style") (NameId "dashed")] + ] + nodes = + [ SubgraphStatement (NewSubgraph Nothing (mkCallingNode <$> M.toList callingNodes)), + SubgraphStatement (NewSubgraph Nothing (mkCalledNode <$> M.toList calledNodes)) + ] + edges = mkEdge <$> inbound + + itemSourceNode :: MakesCallTo -> String + itemSourceNode (MakesCallTo path method _ _) = method <> " " <> path + + itemTargetNode :: MakesCallTo -> String + itemTargetNode (MakesCallTo _ _ comp name) = "[" <> comp <> "]:" <> name + + callingNodes :: Map String Integer + callingNodes = + foldl + (\mp (i, caller) -> M.insert caller i mp) + mempty + ((zip [0 ..] . nub $ itemSourceNode <$> inbound) :: [(Integer, String)]) + + calledNodes :: Map String Integer + calledNodes = + foldl + (\mp (i, called) -> M.insert called i mp) + mempty + ((zip [(fromIntegral $ M.size callingNodes) ..] . nub $ itemTargetNode <$> inbound) :: [(Integer, String)]) + + mkCallingNode :: (String, Integer) -> Statement + mkCallingNode n = + NodeStatement (mkCallingNodeId n) [] + + mkCallingNodeId :: (String, Integer) -> NodeId + mkCallingNodeId (caller, i) = + NodeId (NameId . show $ show i <> ": " <> caller) (Just (PortC CompassW)) + + mkCalledNode :: (String, Integer) -> Statement + mkCalledNode n = + NodeStatement (mkCalledNodeId n) [] + + mkCalledNodeId :: (String, Integer) -> NodeId + mkCalledNodeId (callee, i) = + NodeId (NameId . show $ show i <> ": " <> callee) (Just (PortC CompassE)) + + mkEdge :: MakesCallTo -> Statement + mkEdge item = + EdgeStatement + [ ENodeId NoEdge (mkCallingNodeId (caller, callerId)), + ENodeId DirectedEdge (mkCalledNodeId (callee, calleeId)) + ] + [] + where + caller = itemSourceNode item + callee = itemTargetNode item + callerId = fromMaybe (error "impossible") $ M.lookup caller callingNodes + calleeId = fromMaybe (error "impossible") $ M.lookup callee calledNodes + +------------------------------ + +toCsv :: [MakesCallTo] -> String +toCsv = + intercalate "\n" + . fmap (intercalate ",") + . addhdr + . fmap dolines + where + addhdr :: [[String]] -> [[String]] + addhdr = (["source method", "source path", "target component", "target name"] :) + + dolines :: MakesCallTo -> [String] + dolines (MakesCallTo spath smeth tcomp tname) = [smeth, spath, tcomp, tname] From 6657b855c67d126982d87948eecb7d36007af463 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Fri, 13 Jan 2023 11:29:06 +0100 Subject: [PATCH 27/33] Add "edit on github" button docs (#2983) --- docs/src/conf.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/src/conf.py b/docs/src/conf.py index 1d2c7fa490..ee4d992b35 100644 --- a/docs/src/conf.py +++ b/docs/src/conf.py @@ -113,6 +113,13 @@ html_favicon = '_static/favicon/favicon.ico' html_logo = '_static/image/Wire_logo.svg' +html_context = { + 'display_github': True, + 'github_user': 'wireapp', + 'github_repo': 'wire-server', + 'github_version': 'develop/docs/src/', +} + smv_tag_whitelist = '' smv_branch_whitelist = r'^(install-with-poetry)$' smv_remote_whitelist = r'^(origin)$' From 4f8d1145b80aa9de79946ade9835e812422e8118 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 5 Jan 2023 13:33:26 +0000 Subject: [PATCH 28/33] oauth test script --- hack/bin/oauth.sh | 53 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100755 hack/bin/oauth.sh diff --git a/hack/bin/oauth.sh b/hack/bin/oauth.sh new file mode 100755 index 0000000000..a39a4544d4 --- /dev/null +++ b/hack/bin/oauth.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -e + +# +# It is required to set the `USER` environment variable to the user ID of an existing user. +# Create a user e.g. with `./create_test_user.sh -n 1 -c` +# + +SCOPE="self:read" + +CLIENT=$( + curl -s -X POST localhost:8082/i/oauth/clients \ + -H "Content-Type: application/json" \ + -d '{ + "applicationName":"foobar", + "redirectUrl":"https://example.com" + }' +) + +CLIENT_ID=$(echo "$CLIENT" | jq -r '.clientId') +CLIENT_SECRET=$(echo "$CLIENT" | jq -r '.clientSecret') + +AUTH_CODE=$( + curl -i -s -X POST localhost:8082/oauth/authorization/codes \ + -H 'Z-User: '$USER \ + -H "Content-Type: application/json" \ + -d '{ + "clientId": "'$CLIENT_ID'", + "scope": "'$SCOPE'", + "responseType": "code", + "redirectUri": "https://example.com", + "state": "foobar" + }' | + awk -F ': ' '/^Location/ {print $2}' | awk -F'[=&]' '{print $2}' +) + +ACCESS_TOKEN=$( + curl -s -X POST localhost:8082/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d 'code='$AUTH_CODE'&client_id='$CLIENT_ID'&grant_type=authorization_code&redirect_uri=https://example.com&client_secret='$CLIENT_SECRET | + jq -r '.accessToken' +) + +echo "client id : $CLIENT_ID" +echo "client secret: $CLIENT_SECRET" +echo "scope : $SCOPE" +echo "auth code : $AUTH_CODE" +echo "access token : $ACCESS_TOKEN" + +echo "" +echo "making a request to /self..." +curl -s -H 'Z-OAUTH: Bearer '$ACCESS_TOKEN -H "Content-Type: application/json" localhost:8082/self | jq . From 72468bba955b74768e4b09025ac4934108362b10 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 13 Jan 2023 11:45:41 +0000 Subject: [PATCH 29/33] show instance for OAuthAuthCode --- libs/wire-api/src/Wire/API/OAuth.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs index 3a30041161..19eb165da1 100644 --- a/libs/wire-api/src/Wire/API/OAuth.hs +++ b/libs/wire-api/src/Wire/API/OAuth.hs @@ -218,7 +218,10 @@ instance ToSchema NewOAuthAuthCode where <*> noacState .= field "state" schema newtype OAuthAuthCode = OAuthAuthCode {unOAuthAuthCode :: AsciiBase16} - deriving (Show, Eq, Generic) + deriving (Eq, Generic) + +instance Show OAuthAuthCode where + show _ = "" instance ToSchema OAuthAuthCode where schema = (toText . unOAuthAuthCode) .= parsedText "OAuthAuthCode" (fmap OAuthAuthCode . validateBase16) From d83c6793bb42b0d41f83237622e7cb2e22ea70b1 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 13 Jan 2023 13:28:03 +0100 Subject: [PATCH 30/33] [SQSERVICES-1828] pagination of team members does not work properly for certain team sizes (#2968) --- changelog.d/3-bug-fixes/pr-2968 | 1 + .../src/Brig/User/Search/TeamUserSearch.hs | 18 +++++++++++++++--- .../test/integration/API/TeamUserSearch.hs | 17 +++++++++++------ 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 changelog.d/3-bug-fixes/pr-2968 diff --git a/changelog.d/3-bug-fixes/pr-2968 b/changelog.d/3-bug-fixes/pr-2968 new file mode 100644 index 0000000000..e32c978a07 --- /dev/null +++ b/changelog.d/3-bug-fixes/pr-2968 @@ -0,0 +1 @@ +Fix pagination in team user search (make search key unique) diff --git a/services/brig/src/Brig/User/Search/TeamUserSearch.hs b/services/brig/src/Brig/User/Search/TeamUserSearch.hs index dea5b4dd37..3731f02ab6 100644 --- a/services/brig/src/Brig/User/Search/TeamUserSearch.hs +++ b/services/brig/src/Brig/User/Search/TeamUserSearch.hs @@ -102,12 +102,24 @@ teamUserSearchQuery tid mbSearchText _mRoleFilter mSortBy mSortOrder = mbQStr ) teamFilter - ( maybe + -- in combination with pagination a non-unique search specification can lead to missing results + -- therefore we use the unique `_doc` value as a tie breaker + -- - see https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-sort.html for details on `_doc` + -- - see https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-search-after.html for details on pagination and tie breaker + -- in the latter article it "is advised to duplicate (client side or [...]) the content of the _id field + -- in another field that has doc value enabled and to use this new field as the tiebreaker for the sort" + -- so alternatively we could use the user ID as a tie breaker, but this would require a change in the index mapping + (sorting ++ sortingTieBreaker) + where + sorting :: [ES.DefaultSort] + sorting = + maybe [defaultSort SortByCreatedAt SortOrderDesc | isNothing mbQStr] (\tuSortBy -> [defaultSort tuSortBy (fromMaybe SortOrderAsc mSortOrder)]) mSortBy - ) - where + sortingTieBreaker :: [ES.DefaultSort] + sortingTieBreaker = [ES.DefaultSort (ES.FieldName "_doc") ES.Ascending Nothing Nothing Nothing Nothing] + mbQStr :: Maybe Text mbQStr = case mbSearchText of diff --git a/services/brig/test/integration/API/TeamUserSearch.hs b/services/brig/test/integration/API/TeamUserSearch.hs index ef4ab14088..a57301bb0f 100644 --- a/services/brig/test/integration/API/TeamUserSearch.hs +++ b/services/brig/test/integration/API/TeamUserSearch.hs @@ -111,7 +111,7 @@ testSort brig = do let sortByProperty' :: (TestConstraints m, Ord a) => TeamUserSearchSortBy -> (User -> a) -> TeamUserSearchSortOrder -> m () sortByProperty' = sortByProperty tid users ownerId for_ [SortOrderAsc, SortOrderDesc] $ \sortOrder -> do - -- FUTUREWORK: Test SortByRole when role is avaible in index + -- FUTUREWORK: Test SortByRole when role is available in index sortByProperty' SortByEmail userEmail sortOrder sortByProperty' SortByName userDisplayName sortOrder sortByProperty' SortByHandle (fmap fromHandle . userHandle) sortOrder @@ -144,12 +144,17 @@ testEmptyQuerySortedWithPagination :: TestConstraints m => Brig -> m () testEmptyQuerySortedWithPagination brig = do (tid, userId -> ownerId, _) <- createPopulatedBindingTeamWithNamesAndHandles brig 20 refreshIndex brig - searchResultFirst10 <- executeTeamUserSearchWithMaybeState brig tid ownerId (Just "") Nothing Nothing Nothing (Just $ unsafeRange 10) Nothing - searchResultLast11 <- executeTeamUserSearchWithMaybeState brig tid ownerId (Just "") Nothing Nothing Nothing Nothing (searchPagingState searchResultFirst10) + let teamUserSearch mPs = executeTeamUserSearchWithMaybeState brig tid ownerId (Just "") Nothing (Just SortByRole) (Just SortOrderAsc) (Just $ unsafeRange 10) mPs + searchResultFirst10 <- teamUserSearch Nothing + searchResultNext10 <- teamUserSearch (searchPagingState searchResultFirst10) + searchResultLast1 <- teamUserSearch (searchPagingState searchResultNext10) liftIO $ do searchReturned searchResultFirst10 @?= 10 searchFound searchResultFirst10 @?= 21 searchHasMore searchResultFirst10 @?= Just True - searchReturned searchResultLast11 @?= 11 - searchFound searchResultLast11 @?= 21 - searchHasMore searchResultLast11 @?= Just False + searchReturned searchResultNext10 @?= 10 + searchFound searchResultNext10 @?= 21 + searchHasMore searchResultNext10 @?= Just True + searchReturned searchResultLast1 @?= 1 + searchFound searchResultLast1 @?= 21 + searchHasMore searchResultLast1 @?= Just False From 06224415fefbb0879d9b07fa7b53bf76a41b5b3b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 13 Jan 2023 12:29:08 +0000 Subject: [PATCH 31/33] tagged token, refresh token basic impl --- libs/wire-api/src/Wire/API/OAuth.hs | 46 ++++++++++++-------- libs/wire-api/src/Wire/API/Routes/Public.hs | 2 +- services/brig/src/Brig/API/OAuth.hs | 48 +++++++++++++++------ services/brig/test/integration/API/OAuth.hs | 12 +++--- 4 files changed, 69 insertions(+), 39 deletions(-) diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs index 19eb165da1..e7100200bb 100644 --- a/libs/wire-api/src/Wire/API/OAuth.hs +++ b/libs/wire-api/src/Wire/API/OAuth.hs @@ -316,35 +316,42 @@ instance ToSchema OAuthAccessTokenType where [ element "Bearer" OAuthAccessTokenTypeBearer ] -newtype OAuthAccessToken = OAuthAccessToken {unOAuthAccessToken :: SignedJWT} +data TokenTag = Access | Refresh + +newtype OAuthToken a = OAuthToken {unOAuthToken :: SignedJWT} deriving (Show, Eq, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema OAuthAccessToken + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema (OAuthToken a) -instance ToByteString OAuthAccessToken where - builder = builder . encodeCompact . unOAuthAccessToken +instance ToByteString (OAuthToken a) where + builder = builder . encodeCompact . unOAuthToken -instance FromByteString OAuthAccessToken where +instance FromByteString (OAuthToken a) where parser = do t <- parser @Text case decodeCompact (cs (TE.encodeUtf8 t)) of Left (err :: JWTError) -> fail $ show err - Right jwt -> pure $ OAuthAccessToken jwt + Right jwt -> pure $ OAuthToken jwt -instance ToHttpApiData OAuthAccessToken where +instance ToHttpApiData (OAuthToken a) where toHeader = toByteString' toUrlPiece = cs . toHeader -instance FromHttpApiData OAuthAccessToken where +instance FromHttpApiData (OAuthToken a) where parseHeader = either (Left . cs) pure . runParser parser . cs parseUrlPiece = parseHeader . cs -instance ToSchema OAuthAccessToken where +instance ToSchema (OAuthToken a) where schema = (TE.decodeUtf8 . toByteString') .= withParser schema (either fail pure . runParser parser . cs) +type OAuthAccessToken = OAuthToken 'Access + +type OAuthRefreshToken = OAuthToken 'Refresh + data OAuthAccessTokenResponse = OAuthAccessTokenResponse { oatAccessToken :: OAuthAccessToken, oatTokenType :: OAuthAccessTokenType, - oatExpiresIn :: NominalDiffTime + oatExpiresIn :: NominalDiffTime, + oatRefreshToken :: OAuthRefreshToken } deriving (Eq, Show, Generic) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenResponse) @@ -356,39 +363,40 @@ instance ToSchema OAuthAccessTokenResponse where <$> oatAccessToken .= field "accessToken" schema <*> oatTokenType .= field "tokenType" schema <*> oatExpiresIn .= field "expiresIn" (fromIntegral <$> roundDiffTime .= schema) + <*> oatRefreshToken .= field "refreshToken" schema where roundDiffTime :: NominalDiffTime -> Int32 roundDiffTime = round -data OAuthClaimSet = OAuthClaimSet {jwtClaims :: ClaimsSet, scope :: OAuthScopes} +data OAuthsClaimSet = OAuthsClaimSet {jwtClaims :: ClaimsSet, scope :: OAuthScopes} deriving (Eq, Show, Generic) -instance HasClaimsSet OAuthClaimSet where +instance HasClaimsSet OAuthsClaimSet where claimsSet f s = fmap (\a' -> s {jwtClaims = a'}) (f (jwtClaims s)) -instance A.FromJSON OAuthClaimSet where - parseJSON = A.withObject "OAuthClaimSet" $ \o -> - OAuthClaimSet +instance A.FromJSON OAuthsClaimSet where + parseJSON = A.withObject "OAuthsClaimSet" $ \o -> + OAuthsClaimSet <$> A.parseJSON (A.Object o) <*> o A..: "scope" -instance A.ToJSON OAuthClaimSet where +instance A.ToJSON OAuthsClaimSet where toJSON s = ins "scope" (scope s) (A.toJSON (jwtClaims s)) where ins k v (A.Object o) = A.Object $ M.insert k (A.toJSON v) o ins _ _ a = a -csUserId :: OAuthClaimSet -> Maybe UserId +csUserId :: OAuthsClaimSet -> Maybe UserId csUserId = view claimSub >=> preview string >=> either (const Nothing) pure . parseIdFromText -hasScope :: OAuthScope -> OAuthClaimSet -> Bool +hasScope :: OAuthScope -> OAuthsClaimSet -> Bool hasScope s claims = s `Set.member` unOAuthScopes (scope claims) -verify :: JWK -> SignedJWT -> IO (Either JWTError OAuthClaimSet) +verify :: JWK -> SignedJWT -> IO (Either JWTError OAuthsClaimSet) verify k jwt = runJOSE $ do let audCheck = const True verifyJWT (defaultJWTValidationSettings audCheck) k jwt diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs index 8bec0f4df3..b34896871f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public.hs @@ -310,7 +310,7 @@ checkZAuthOrOAuth oauthScope mJwk req = maybe tryOAuth (pure . Right) tryZUserAu verifyOAuthToken :: (Bearer OAuthAccessToken, JWK) -> DelayedIO (Either ServerError UserId) verifyOAuthToken (token, key) = do - verifiedOrError <- mapLeft (invalidOAuthToken . cs . show) <$> liftIO (verify key (unOAuthAccessToken . unBearer $ token)) + verifiedOrError <- mapLeft (invalidOAuthToken . cs . show) <$> liftIO (verify key (unOAuthToken . unBearer $ token)) pure $ verifiedOrError >>= \claimSet -> if hasScope oauthScope claimSet diff --git a/services/brig/src/Brig/API/OAuth.hs b/services/brig/src/Brig/API/OAuth.hs index 9be87e6ec0..5d1daa714b 100644 --- a/services/brig/src/Brig/API/OAuth.hs +++ b/services/brig/src/Brig/API/OAuth.hs @@ -104,26 +104,38 @@ createNewOAuthAuthCode uid (NewOAuthAuthCode cid scope responseType redirectUrl createAccessToken :: (Member Now r, Member Jwk r) => OAuthAccessTokenRequest -> (Handler r) OAuthAccessTokenResponse createAccessToken req = do unlessM (Opt.setOAuthEnabled <$> view settings) $ throwStd $ errorToWai @'OAuthFeatureDisabled - (authCodeCid, authCodeUserId, authCodeScopes, authCodeRedirectUrl) <- + (cid, uid, scope, uri) <- lift (wrapClient $ lookupAndDeleteOAuthAuthCode (oatCode req)) >>= maybe (throwStd $ errorToWai @'OAuthAuthCodeNotFound) pure - oauthClient <- getOAuthClient authCodeUserId (oatClientId req) >>= maybe (throwStd $ errorToWai @'OAuthClientNotFound) pure + oauthClient <- getOAuthClient uid (oatClientId req) >>= maybe (throwStd $ errorToWai @'OAuthClientNotFound) pure unlessM (verifyClientSecret (oatClientSecret req) (ocId oauthClient)) $ throwStd $ errorToWai @'InvalidClientCredentials - unless (authCodeCid == oatClientId req) $ throwStd $ errorToWai @'InvalidClientCredentials + unless (cid == oatClientId req) $ throwStd $ errorToWai @'InvalidClientCredentials unless (ocRedirectUrl oauthClient == oatRedirectUri req) $ throwStd $ errorToWai @'RedirectUrlMissMatch - unless (authCodeRedirectUrl == oatRedirectUri req) $ throwStd $ errorToWai @'RedirectUrlMissMatch + unless (uri == oatRedirectUri req) $ throwStd $ errorToWai @'RedirectUrlMissMatch - domain <- Opt.setFederationDomain <$> view settings exp <- fromIntegral . Opt.setOAuthAccessTokenExpirationTimeSecs <$> view settings - claims <- mkClaims authCodeUserId domain authCodeScopes exp fp <- view settings >>= maybe (throwStd $ errorToWai @'JwtError) pure . Opt.setOAuthJwkKeyPair key <- lift (liftSem $ Jwk.get fp) >>= maybe (throwStd $ errorToWai @'JwtError) pure - token <- OAuthAccessToken <$> signJwtToken key claims - pure $ OAuthAccessTokenResponse token OAuthAccessTokenTypeBearer exp + accessToken <- mkAccessToken key uid scope + refreshToken <- mkRefreshToken key + pure $ OAuthAccessTokenResponse accessToken OAuthAccessTokenTypeBearer exp refreshToken where - mkClaims :: (Member Now r) => UserId -> Domain -> OAuthScopes -> NominalDiffTime -> (Handler r) OAuthClaimSet - mkClaims u domain scopes ttl = do + mkRefreshToken :: (Member Now r) => JWK -> (Handler r) OAuthRefreshToken + mkRefreshToken key = do + sub <- maybe (throwStd $ errorToWai @'JwtError) pure $ ("c5c126ce-58b3-4391-aa19-c70f8759b623" :: Text) ^? stringOrUri + let claims = emptyClaimsSet & claimSub ?~ sub + OAuthToken <$> signRefreshToken key claims + + mkAccessToken :: (Member Now r, Member Jwk r) => JWK -> UserId -> OAuthScopes -> (Handler r) OAuthAccessToken + mkAccessToken key uid scope = do + domain <- Opt.setFederationDomain <$> view settings + exp <- fromIntegral . Opt.setOAuthAccessTokenExpirationTimeSecs <$> view settings + claims <- mkAccessTokenClaims uid domain scope exp + OAuthToken <$> signAccessToken key claims + + mkAccessTokenClaims :: (Member Now r) => UserId -> Domain -> OAuthScopes -> NominalDiffTime -> (Handler r) OAuthsClaimSet + mkAccessTokenClaims u domain scopes ttl = do iat <- lift (liftSem Now.get) uri <- maybe (throwStd $ errorToWai @'JwtError) pure $ domainText domain ^? stringOrUri sub <- maybe (throwStd $ errorToWai @'JwtError) pure $ idToText u ^? stringOrUri @@ -135,10 +147,10 @@ createAccessToken req = do & claimIat ?~ NumericDate iat & claimSub ?~ sub & claimExp ?~ NumericDate exp - pure $ OAuthClaimSet claimSet scopes + pure $ OAuthsClaimSet claimSet scopes - signJwtToken :: JWK -> OAuthClaimSet -> (Handler r) SignedJWT - signJwtToken key claims = do + signAccessToken :: JWK -> OAuthsClaimSet -> (Handler r) SignedJWT + signAccessToken key claims = do jwtOrError <- liftIO $ doSignClaims either (const $ throwStd $ errorToWai @'JwtError) pure jwtOrError where @@ -147,6 +159,16 @@ createAccessToken req = do algo <- bestJWSAlg key signJWT key (newJWSHeader ((), algo)) claims + signRefreshToken :: JWK -> ClaimsSet -> (Handler r) SignedJWT + signRefreshToken key claims = do + jwtOrError <- liftIO $ doSignClaims + either (const $ throwStd $ errorToWai @'JwtError) pure jwtOrError + where + doSignClaims :: IO (Either JWTError SignedJWT) + doSignClaims = runJOSE $ do + algo <- bestJWSAlg key + signClaims key (newJWSHeader ((), algo)) claims + verifyClientSecret :: OAuthClientPlainTextSecret -> OAuthClientId -> (Handler r) Bool verifyClientSecret secret cid = do let plainTextPw = PlainTextPassword $ toText $ unOAuthClientPlainTextSecret secret diff --git a/services/brig/test/integration/API/OAuth.hs b/services/brig/test/integration/API/OAuth.hs index 08f2a46f17..0009ef7d66 100644 --- a/services/brig/test/integration/API/OAuth.hs +++ b/services/brig/test/integration/API/OAuth.hs @@ -73,7 +73,7 @@ tests m b n o = do test m "create token" $ testCreateAccessTokenAccessDeniedWhenDisabled o b ], testGroup "accessing a resource" $ - [ test m "success (internal," $ testAccessResourceSuccessInternal b, + [ test m "success (internal)" $ testAccessResourceSuccessInternal b, test m "success (nginz)" $ testAccessResourceSuccessNginz b n, test m "insufficient scope" $ testAccessResourceInsufficientScope b, test m "expired token" $ testAccessResourceExpiredToken o b, @@ -150,8 +150,8 @@ testCreateAccessTokenSuccess opts brig = do const 404 === statusCode const (Just "not-found") === fmap Error.label . responseJsonMaybe k <- liftIO $ readJwk (fromMaybe "path to jwk not set" (Opt.setOAuthJwkKeyPair $ Opt.optSettings opts)) <&> fromMaybe (error "invalid key") - verifiedOrError <- liftIO $ verify k (unOAuthAccessToken $ oatAccessToken accessToken) - verifiedOrErrorWithWrongKey <- liftIO $ verify wrongKey (unOAuthAccessToken $ oatAccessToken accessToken) + verifiedOrError <- liftIO $ verify k (unOAuthToken $ oatAccessToken accessToken) + verifiedOrErrorWithWrongKey <- liftIO $ verify wrongKey (unOAuthToken $ oatAccessToken accessToken) let expectedDomain = domainText $ Opt.setFederationDomain $ Opt.optSettings opts liftIO $ do isRight verifiedOrError @?= True @@ -343,9 +343,9 @@ testAccessResourceInvalidSignature opts brig = do let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl accessToken <- createOAuthAccessToken brig accessTokenRequest key <- liftIO $ readJwk (fromMaybe "path to jwk not set" (Opt.setOAuthJwkKeyPair $ Opt.optSettings opts)) <&> fromMaybe (error "invalid key") - claimSet <- fromRight (error "token invalid") <$> liftIO (verify key (unOAuthAccessToken $ oatAccessToken accessToken)) + claimSet <- fromRight (error "token invalid") <$> liftIO (verify key (unOAuthToken $ oatAccessToken accessToken)) tokenSignedWithWrongKey <- signJwtToken wrongKey claimSet - get (brig . paths ["self"] . zOAuthHeader (OAuthAccessToken tokenSignedWithWrongKey)) !!! do + get (brig . paths ["self"] . zOAuthHeader (OAuthToken tokenSignedWithWrongKey)) !!! do const 403 === statusCode const "Access denied" === statusMessage const (Just "Invalid token: JWSError JWSInvalidSignature") === responseBody @@ -410,7 +410,7 @@ generateOAuthClientAndAuthCode brig uid scope url = do getQueryParamValue :: ByteString -> RedirectUrl -> Maybe ByteString getQueryParamValue key uri = snd <$> find ((== key) . fst) (getQueryParams uri) -signJwtToken :: JWK -> OAuthClaimSet -> Http SignedJWT +signJwtToken :: JWK -> OAuthsClaimSet -> Http SignedJWT signJwtToken key claims = do jwtOrError <- liftIO $ doSignClaims either (const $ error "jwt error") pure jwtOrError From 8760b4978ccb039b229d458b7a08136a05e12ff9 Mon Sep 17 00:00:00 2001 From: Stefan Matting Date: Fri, 13 Jan 2023 14:21:16 +0100 Subject: [PATCH 32/33] Fix Federation Docs errata (#2984) --- .../how-to/install/configure-federation.md | 14 +++--- docs/src/understand/federation/api.md | 4 +- .../src/understand/federation/architecture.md | 23 +++++----- .../federation/backend-communication.md | 46 +++++++------------ 4 files changed, 37 insertions(+), 50 deletions(-) diff --git a/docs/src/how-to/install/configure-federation.md b/docs/src/how-to/install/configure-federation.md index c7e46849bc..69396c92b5 100644 --- a/docs/src/how-to/install/configure-federation.md +++ b/docs/src/how-to/install/configure-federation.md @@ -14,7 +14,7 @@ detailed in the sections below: - Choose a backend domain name -- DNS setup for federation (including a `SRV` record) +- DNS setup for federation (including an `SRV` record) - Generate and configure TLS certificates: @@ -31,7 +31,7 @@ detailed in the sections below: ## Choose a Backend Domain As of the release \[helm chart 0.129.0, Wire docker version 2.94.0\] from -2020-12-15, the `federationDomain` is a mandatory configuration setting, which +2020-12-15, `federationDomain` is a mandatory configuration setting, which defines the {ref}`backend domain ` of your installation. Regardless of whether you want to enable federation for a backend or not, you must decide what its domain is going to be. This helps in keeping @@ -118,7 +118,7 @@ The fields of the SRV record need to be populated as follows - `weight`: \>0 for your server to be reachable. A good default value could be 10 - `port`: `443` -- `target`: the infra domain +- `target`: the infrastructure domain To give an example, assuming @@ -237,7 +237,7 @@ trust when interacting with other backends. ### (B) Manual server and client certificates Use your usual method of obtaining X.509 certificates for your {ref}`federation -infra domain ` (alongside the other domains needed for a +infrastructure domain ` (alongside the other domains needed for a wire-server installation). You can use one single certificate and key for both server and client @@ -266,7 +266,7 @@ X509v3 extensions: TLS Web Server Authentication, TLS Web Client Authentication ``` -And your {ref}`federation infra domain ` (e.g. +And your {ref}`federation infrastructure domain ` (e.g. `federator.wire.example.com` from the running example) needs to either figure explictly in the list of your SAN (Subject Alternative Name): @@ -304,7 +304,7 @@ The *server certificate* and *private key* need to be configured in just the federator component. If you have installed wire-server before without federation, server certificates may already be configured *(though you probably need to create new certificates to include the -federation infra domain if you\'re not making use of wildcard +federation infrastructure domain if you\'re not making use of wildcard certificates)*. Server certificates go here: ``` yaml @@ -515,7 +515,7 @@ Ensure that the IP matches where your backend ingress runs. Refer to {ref}`how-to-see-tls-certs` and set DOMAIN to your -{ref}`federation infra domain `. They should include your domain as part of the SAN (Subject +{ref}`federation infrastructure domain `. They should include your domain as part of the SAN (Subject Alternative Names) and not have expired. ### Manually test that federation works diff --git a/docs/src/understand/federation/api.md b/docs/src/understand/federation/api.md index 2a8325606c..e48e642294 100644 --- a/docs/src/understand/federation/api.md +++ b/docs/src/understand/federation/api.md @@ -231,9 +231,9 @@ their precise inputs and outputs. In the following the interactions between *Federator* and *Federation Ingress* components of the backends involved are omitted for simplicity. Also the backend -domain and infra domain are assumed the same. +domain and infrastructure domain are assumed the same. -Additionally we assume that the backend domain and the infra domain of +Additionally we assume that the backend domain and the infrastructure domain of the respective backends involved are the same and each domain identifies a distinct backend. diff --git a/docs/src/understand/federation/architecture.md b/docs/src/understand/federation/architecture.md index 392806ac91..6bb1e782bc 100644 --- a/docs/src/understand/federation/architecture.md +++ b/docs/src/understand/federation/architecture.md @@ -6,19 +6,18 @@ ## Backends In the following we call a **backend** the set of servers, databases and DNS -configurations that together form one single Wire Server entity as seen from +configurations that together form one single Wire Server entity as seen from the outside. It can also be called a Wire \"instance\" or \"server\" or \"Wire installation\". Every resource (e.g. users, conversations, assets and teams) exists and is *owned* by a single backend, which we can refer to as that resource\'s backend. The communication between federated backends is facilitated by two components in -each backend: {ref}`federation_ingress` and {ref}`federator`. The -*Federation Ingress* is, as the name suggests the -ingress point for incoming connections from other backends, which are then -forwarded to the *Federator*. The *Federator* forwards requests -to internal components. It also acts as a *egress* point for requests from -internal backend components to other, remote backends. +each backend: {ref}`federation_ingress` and {ref}`federator`. The *Federation +Ingress* is, as the name suggests, the ingress point for incoming connections +from other backends, which are then forwarded to the *Federator*. The +*Federator* forwards requests to internal components. It also acts as a *egress* +point for requests from internal backend components to other, remote backends. ![image](img/federated-backend-architecture.png) @@ -32,7 +31,7 @@ internal backend components to other, remote backends. Each backend has two domain: an {ref}`infrastructure domain ` and a {ref}`backend domain `. -The **infrastructure domain** (short **infra domain**) is the domain name under which the backend +The **infrastructure domain** is the domain name under which the backend is actually reachable via the network. It is also the domain name that each backend uses in authenticating itself to other backends. @@ -42,7 +41,7 @@ context of federation. The distinction between the two domains allows the owner of a backend domain, e.g. `example.com`, to host their Wire backend under a -different infra domain, e.g. `wire.infra.example.com`. +different infrastructure domain, e.g. `wire.infra.example.com`. (federation_ingress)= @@ -53,7 +52,7 @@ ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) and uses [nginx](https://nginx.org/en/) as its underlying software. It is configured with a set of X.509 certificates, which acts as root of -trust for the authentication of the infra domain of remote backends, as +trust for the authentication of the infrastructure domain of remote backends, as well as with a certificate, which it uses to authenticate itself toward other backends. @@ -74,7 +73,7 @@ point for other backend components. It can be configured to use an {ref}`allow list ` to authorize incoming and outgoing connections, and it keeps an X.509 client certificate for the -backend\'s infra domain to authenticate itself towards other backends. +backend\'s infrastructure domain to authenticate itself towards other backends. Additionally, it requires a connection to a DNS resolver to {ref}`discover` other backends. @@ -97,7 +96,7 @@ from remote backends (forwarded via the local 1. Discover the mapping between backend domain claimed by the remote backend and its infra domain, -2. Verify that the discovered infra domain matches the domain in the +2. Verify that the discovered infrastructure domain matches the domain in the remote backend\'s client certificate, 3. If enabled, ensure that the backend domain of the other backend is in the allow list. diff --git a/docs/src/understand/federation/backend-communication.md b/docs/src/understand/federation/backend-communication.md index 7fa3e71cb9..a71c6e158b 100644 --- a/docs/src/understand/federation/backend-communication.md +++ b/docs/src/understand/federation/backend-communication.md @@ -9,7 +9,7 @@ need to ensure the following: - **Authentication** - Determine the identity (infra domain name) of the other backend. + Determine the identity (infrastructure domain name) of the other backend. - **Discovery** @@ -36,7 +36,7 @@ Conversely, every *Federator* needs to be provisioned with a client certificate which it uses to authenticate itself towards other backends. Note that the client certificate is required to be issued with the backend\'s -infra domain as one of the subject alternative names (SAN), which is defined in +infrastructure domain as one of the subject alternative names (SAN), which is defined in [RFC 5280](https://tools.ietf.org/html/rfc5280). See {ref}`federation-certificate-setup` for technical instructions. @@ -48,7 +48,7 @@ with an `AuthenticationFailure` error. ## Discovery -The discovery process allows a backend to determine the infra domain of +The discovery process allows a backend to determine the infrastructure domain of a given backend domain. This step is necessary in two scenarios: @@ -56,21 +56,19 @@ This step is necessary in two scenarios: - A backend would like to establish a connection to another backend that it only knows the backend domain of. This is the case, for example, when a user of a local backend searches for a - {ref}`qualified username `, which only includes that user\'s backend\'s backend - domain. + {ref}`qualified username `, which only includes the backend domain of that user's backend. - When receiving a message from another backend that authenticates - with a given infra domain and claims to represent a given backend + with a given infrastructure domain and claims to represent a given backend domain, a backend would like to ensure the backend domain owner - authorized the owner of the infra domain to run their Wire backend. + authorized the owner of the infrastructure domain to run their Wire backend. To make discovery possible, any party hosting a Wire backend has to -announce the infra domain via a DNS *SRV* record as defined in [RFC +announce the infrastructure domain via a DNS *SRV* record as defined in [RFC 2782](https://tools.ietf.org/html/rfc2782) with `service = wire-server-federator, proto = tcp` and with `name` pointing -to the backend\'s domain and *target* to the backend\'s infra domain. +to the backend\'s domain and *target* to the backend\'s infrastructure domain. -For example, Company A with backend domain *company-a.com* and infra -domain *wire.company-a.com* could publish +For example, Company A with backend domain *company-a.com* and infrastructure domain *wire.company-a.com* could publish ``` bash _wire-server-federator._tcp.company-a.com. 600 IN SRV 10 5 443 federator.wire.company-a.com. @@ -83,34 +81,24 @@ In case this process fails the Federator fails to forward the request with a `Di (dns-scope)= -### DNS Scope - -The network scope of the SRV record (as well as that of the DNS records -for backend and infra domain), depends on the desired federation -topology in the same way as other parameters such as the availability of -the CA certificate that allows authentication of the *Federation -Ingress*\' server certificate or the *Federator*\'s client certificate. -The general rule is that the SRV entry should be \"visible\" from the -point of view of the desired federation partners. The exact scope -strongly depends on the network architecture of the backends involved. (srv-ttl-and-caching)= ### SRV TTL and Caching After retrieving the SRV record for a given domain, the local backend -caches the *backend domain \<\--\> infra domain* mapping for the +caches the *backend domain \<\--\> infrastructure domain* mapping for the duration indicated in the TTL field of the record. Due to this caching behavior, the TTL value of the SRV record dictates at which intervals remote backends will refresh their mapping of the -local backend\'s backend domain to infra domain. As a consequence a +local backend\'s backend domain to infrastructure domain. As a consequence a value in the order of magnitude of 24 hours will reduce the amount of overhead for remote backends. -On the other hand in the setup phase of a backend, or when a change of infra +On the other hand in the setup phase of a backend, or when a change of infrastructure domain is required, a TTL value in the magnitude of a few minutes allows remote -backends to recover more quickly from a change of the infra domain. +backends to recover more quickly from a change of the infrastructure domain. (authorization)= @@ -122,10 +110,10 @@ After an incoming connection is authenticated the backend authorizes the request. It does so by verifying that the backend domain of the sender is contained in the {ref}`domain allow list `. -Since the request is authenticated only by the infra domain the sending backend +Since the request is authenticated only by the infrastructure domain the sending backend is required to add its backend domain as a `Wire-Origin-Domain` header to the request. The receiving backend follows the process described in {ref}`discovery` -and verifies that the discovered infra domain for the backend domain indicated +and verifies that the discovered infrastructure domain for the backend domain indicated in the `Wire-Origin-Domain` header is one of the Subject Alternative Names contained in the client certificate used to sign the request. If this is not the case, the receiving backend fails the request with a `ValidationError`. @@ -150,8 +138,8 @@ details. ## Example The following is an example for the message and information flow between -a backend with backend domain `a.com` and infra domain `infra.a.com` and -another backend with backend domain `b.com` and infra domain +a backend with backend domain `a.com` and infrastructure domain `infra.a.com` and +another backend with backend domain `b.com` and infrastructure domain `infra.b.com`. The content and format of the message is meant to be representative. For From 45cd5a11636d75f942782444048931aad6080dc5 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 16 Jan 2023 10:28:17 +0000 Subject: [PATCH 33/33] improve script --- hack/bin/{oauth.sh => oauth_test.sh} | 47 ++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) rename hack/bin/{oauth.sh => oauth_test.sh} (50%) diff --git a/hack/bin/oauth.sh b/hack/bin/oauth_test.sh similarity index 50% rename from hack/bin/oauth.sh rename to hack/bin/oauth_test.sh index a39a4544d4..d2c891c245 100755 --- a/hack/bin/oauth.sh +++ b/hack/bin/oauth_test.sh @@ -2,10 +2,39 @@ set -e -# -# It is required to set the `USER` environment variable to the user ID of an existing user. -# Create a user e.g. with `./create_test_user.sh -n 1 -c` -# +USAGE="This script tests the OAuth2 flow by creating a client, requesting an authorization code, and +then requesting an access token. It then uses the access token to make a request to /self. + +Create a user first with './create_test_user.sh -n 1 -c'. Then use the user ID to call this script. + +USAGE: $0 + -u : User ID +" + +unset -v USER + +while getopts ":u:" opt; do + case ${opt} in + u) + USER="$OPTARG" + ;; + \?) + echo "$USAGE" 1>&2 + exit 1 + ;; + :) + echo "-$OPTARG" requires an argument 1>&2 + exit 1 + ;; + esac +done +shift $((OPTIND - 1)) + +if [ -z "$USER" ]; then + echo 'missing option -u ' 1>&2 + echo "$USAGE" 1>&2 + exit 1 +fi SCOPE="self:read" @@ -23,11 +52,11 @@ CLIENT_SECRET=$(echo "$CLIENT" | jq -r '.clientSecret') AUTH_CODE=$( curl -i -s -X POST localhost:8082/oauth/authorization/codes \ - -H 'Z-User: '$USER \ + -H 'Z-User: '"$USER" \ -H "Content-Type: application/json" \ -d '{ - "clientId": "'$CLIENT_ID'", - "scope": "'$SCOPE'", + "clientId": "'"$CLIENT_ID"'", + "scope": "'"$SCOPE"'", "responseType": "code", "redirectUri": "https://example.com", "state": "foobar" @@ -38,7 +67,7 @@ AUTH_CODE=$( ACCESS_TOKEN=$( curl -s -X POST localhost:8082/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d 'code='$AUTH_CODE'&client_id='$CLIENT_ID'&grant_type=authorization_code&redirect_uri=https://example.com&client_secret='$CLIENT_SECRET | + -d 'code='"$AUTH_CODE"'&client_id='"$CLIENT_ID"'&grant_type=authorization_code&redirect_uri=https://example.com&client_secret='"$CLIENT_SECRET" | jq -r '.accessToken' ) @@ -50,4 +79,4 @@ echo "access token : $ACCESS_TOKEN" echo "" echo "making a request to /self..." -curl -s -H 'Z-OAUTH: Bearer '$ACCESS_TOKEN -H "Content-Type: application/json" localhost:8082/self | jq . +curl -s -H 'Z-OAUTH: Bearer '"$ACCESS_TOKEN" -H "Content-Type: application/json" localhost:8082/self | jq .