From fde20136efcdaa8fd594b8591d0a1b3745f15ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lesimple?= Date: Thu, 15 Oct 2020 16:32:37 +0000 Subject: [PATCH] Initial commit --- .dockerignore | 4 + .github/workflows/documentation.yml | 32 + .github/workflows/tests.yml | 29 + .gitignore | 1 + AUTHORS | 12 + CONTRIBUTING.md | 83 + CONTRIBUTORS | 15 + DESIGN.md | 40 + LICENSE | 192 +++ MAINTAINERS | 12 + README.md | 149 ++ bin/admin/build-and-install-ttyrec.sh | 57 + bin/admin/check-consistency.pl | 715 ++++++++ bin/admin/check-ssh-hardening.pl | 545 ++++++ bin/admin/fix-group-gid.sh | 128 ++ bin/admin/fixrights.sh | 63 + bin/admin/grant-all-restricted-commands-to.sh | 60 + bin/admin/install | 1294 +++++++++++++++ bin/admin/osh-sync-watcher.sh | 135 ++ bin/admin/packages-check.sh | 146 ++ bin/admin/rename-group.sh | 110 ++ bin/admin/restore-account.sh | 61 + bin/admin/setup-encryption.sh | 205 +++ bin/admin/setup-first-admin-account.sh | 25 + bin/admin/setup-gpg.sh | 141 ++ bin/admin/unlock-home.sh | 44 + bin/cron/osh-backup-acl-keys.sh | 125 ++ bin/cron/osh-compress-old-logs.sh | 36 + bin/cron/osh-encrypt-rsync.pl | 509 ++++++ bin/cron/osh-lingering-sessions-reaper.sh | 47 + bin/cron/osh-orphaned-homedir.sh | 52 + bin/cron/osh-piv-grace-reaper.pl | 105 ++ bin/cron/osh-rotate-ttyrec.sh | 36 + bin/dev/debug_toggle.sh | 31 + bin/dev/perl-check.sh | 48 + bin/dev/perl-critic.sh | 19 + bin/dev/perl-tidy.sh | 48 + bin/dev/perl-use-all.pl | 56 + bin/dev/perlcriticrc | 44 + bin/dev/shell-check.sh | 42 + bin/helper/osh-accountAddGroupServer | 83 + bin/helper/osh-accountCreate | 436 +++++ bin/helper/osh-accountDelete | 218 +++ bin/helper/osh-accountGeneratePassword | 56 + bin/helper/osh-accountGetPasswordInfo | 91 + bin/helper/osh-accountListEgressKeys | 74 + bin/helper/osh-accountListIngressKeys | 87 + bin/helper/osh-accountListPasswords | 74 + bin/helper/osh-accountMFAResetPassword | 94 ++ bin/helper/osh-accountMFAResetTOTP | 91 + bin/helper/osh-accountModify | 334 ++++ bin/helper/osh-accountModifyCommand | 141 ++ bin/helper/osh-accountModifyPersonalAccess | 106 ++ bin/helper/osh-accountPIV | 187 +++ bin/helper/osh-accountUnexpire | 95 ++ bin/helper/osh-adminMaintenance | 113 ++ bin/helper/osh-groupAddServer | 114 ++ bin/helper/osh-groupAddSymlinkToAccount | 147 ++ bin/helper/osh-groupCreate | 287 ++++ bin/helper/osh-groupDelete | 196 +++ bin/helper/osh-groupGeneratePassword | 56 + bin/helper/osh-groupModify | 128 ++ bin/helper/osh-groupSetRole | 140 ++ bin/helper/osh-selfMFASetupPassword | 112 ++ bin/helper/osh-selfMFASetupTOTP | 71 + .../check-active-account-fortestsonly.pl | 32 + bin/other/check-active-account-simple.pl | 54 + bin/plugin/admin/adminMaintenance | 46 + bin/plugin/admin/adminMaintenance.json | 9 + bin/plugin/admin/adminSudo | 73 + bin/plugin/admin/adminSudo.json | 8 + bin/plugin/group-aclkeeper/groupAddServer | 144 ++ .../group-aclkeeper/groupAddServer.json | 14 + bin/plugin/group-aclkeeper/groupDelServer | 100 ++ .../group-aclkeeper/groupDelServer.json | 14 + .../group-gatekeeper/groupAddGuestAccess | 93 ++ .../group-gatekeeper/groupAddGuestAccess.json | 17 + bin/plugin/group-gatekeeper/groupAddMember | 46 + .../group-gatekeeper/groupAddMember.json | 10 + .../group-gatekeeper/groupDelGuestAccess | 84 + .../group-gatekeeper/groupDelGuestAccess.json | 17 + bin/plugin/group-gatekeeper/groupDelMember | 46 + .../group-gatekeeper/groupDelMember.json | 10 + .../group-gatekeeper/groupListGuestAccesses | 52 + .../groupListGuestAccesses.json | 9 + bin/plugin/group-owner/groupAddAclkeeper | 40 + bin/plugin/group-owner/groupAddAclkeeper.json | 10 + bin/plugin/group-owner/groupAddGatekeeper | 42 + .../group-owner/groupAddGatekeeper.json | 10 + bin/plugin/group-owner/groupAddOwner | 44 + bin/plugin/group-owner/groupAddOwner.json | 10 + bin/plugin/group-owner/groupDelAclkeeper | 40 + bin/plugin/group-owner/groupDelAclkeeper.json | 10 + bin/plugin/group-owner/groupDelGatekeeper | 42 + .../group-owner/groupDelGatekeeper.json | 10 + bin/plugin/group-owner/groupDelOwner | 42 + bin/plugin/group-owner/groupDelOwner.json | 10 + bin/plugin/group-owner/groupGeneratePassword | 79 + .../group-owner/groupGeneratePassword.json | 11 + bin/plugin/group-owner/groupModify | 65 + bin/plugin/group-owner/groupModify.json | 11 + bin/plugin/group-owner/groupTransmitOwnership | 62 + .../group-owner/groupTransmitOwnership.json | 10 + bin/plugin/open/alive | 78 + bin/plugin/open/alive.json | 6 + bin/plugin/open/batch | 66 + bin/plugin/open/clush | 106 ++ bin/plugin/open/groupInfo | 203 +++ bin/plugin/open/groupInfo.json | 7 + bin/plugin/open/groupList | 62 + bin/plugin/open/groupList.json | 6 + bin/plugin/open/groupListPasswords | 60 + bin/plugin/open/groupListPasswords.json | 7 + bin/plugin/open/groupListServers | 68 + bin/plugin/open/groupListServers.json | 8 + bin/plugin/open/help | 162 ++ bin/plugin/open/help.json | 5 + bin/plugin/open/info | 256 +++ bin/plugin/open/info.json | 5 + bin/plugin/open/lock | 42 + bin/plugin/open/lock.json | 5 + bin/plugin/open/mtr | 49 + bin/plugin/open/mtr.json | 7 + bin/plugin/open/nc | 89 + bin/plugin/open/nc.json | 10 + bin/plugin/open/ping | 89 + bin/plugin/open/ping.json | 7 + bin/plugin/open/scp | 203 +++ bin/plugin/open/scp.json | 3 + bin/plugin/open/selfAddIngressKey | 122 ++ bin/plugin/open/selfAddIngressKey.json | 9 + bin/plugin/open/selfDelIngressKey | 113 ++ bin/plugin/open/selfDelIngressKey.json | 10 + bin/plugin/open/selfForgetHostKey | 94 ++ bin/plugin/open/selfForgetHostKey.json | 9 + bin/plugin/open/selfGenerateEgressKey | 137 ++ bin/plugin/open/selfGenerateEgressKey.json | 12 + bin/plugin/open/selfGeneratePassword | 70 + bin/plugin/open/selfGeneratePassword.json | 9 + bin/plugin/open/selfGenerateProxyPassword | 72 + .../open/selfGenerateProxyPassword.json | 9 + bin/plugin/open/selfListAccesses | 43 + bin/plugin/open/selfListAccesses.json | 8 + bin/plugin/open/selfListEgressKeys | 55 + bin/plugin/open/selfListIngressKeys | 39 + bin/plugin/open/selfListIngressKeys.json | 5 + bin/plugin/open/selfListPasswords | 41 + bin/plugin/open/selfListPasswords.json | 5 + bin/plugin/open/selfListSessions | 194 +++ bin/plugin/open/selfMFAResetPassword | 29 + bin/plugin/open/selfMFAResetPassword.json | 6 + bin/plugin/open/selfMFAResetTOTP | 29 + bin/plugin/open/selfMFAResetTOTP.json | 6 + bin/plugin/open/selfMFASetupPassword | 88 + bin/plugin/open/selfMFASetupPassword.json | 7 + bin/plugin/open/selfMFASetupTOTP | 75 + bin/plugin/open/selfMFASetupTOTP.json | 7 + bin/plugin/open/selfPlaySession | 99 ++ bin/plugin/open/selfPlaySession.json | 9 + bin/plugin/open/unlock | 47 + bin/plugin/open/unlock.json | 5 + .../restricted/accountAddPersonalAccess | 124 ++ .../restricted/accountAddPersonalAccess.json | 22 + bin/plugin/restricted/accountCreate | 131 ++ bin/plugin/restricted/accountCreate.json | 14 + .../restricted/accountDelPersonalAccess | 98 ++ .../restricted/accountDelPersonalAccess.json | 15 + bin/plugin/restricted/accountDelete | 99 ++ bin/plugin/restricted/accountDelete.json | 10 + bin/plugin/restricted/accountGeneratePassword | 83 + .../restricted/accountGeneratePassword.json | 11 + bin/plugin/restricted/accountGrantCommand | 63 + .../restricted/accountGrantCommand.json | 10 + bin/plugin/restricted/accountInfo | 254 +++ bin/plugin/restricted/accountInfo.json | 7 + bin/plugin/restricted/accountList | 169 ++ bin/plugin/restricted/accountList.json | 9 + bin/plugin/restricted/accountListAccesses | 64 + .../restricted/accountListAccesses.json | 10 + bin/plugin/restricted/accountListEgressKeys | 66 + .../restricted/accountListEgressKeys.json | 7 + bin/plugin/restricted/accountListIngressKeys | 59 + .../restricted/accountListIngressKeys.json | 7 + bin/plugin/restricted/accountListPasswords | 54 + .../restricted/accountListPasswords.json | 7 + bin/plugin/restricted/accountMFAResetPassword | 43 + .../restricted/accountMFAResetPassword.json | 8 + bin/plugin/restricted/accountMFAResetTOTP | 43 + .../restricted/accountMFAResetTOTP.json | 8 + bin/plugin/restricted/accountModify | 95 ++ bin/plugin/restricted/accountModify.json | 12 + bin/plugin/restricted/accountPIV | 91 + bin/plugin/restricted/accountPIV.json | 12 + bin/plugin/restricted/accountRevokeCommand | 56 + .../restricted/accountRevokeCommand.json | 9 + bin/plugin/restricted/accountUnexpire | 66 + bin/plugin/restricted/accountUnexpire.json | 7 + bin/plugin/restricted/groupCreate | 151 ++ bin/plugin/restricted/groupCreate.json | 16 + bin/plugin/restricted/groupDelete | 74 + bin/plugin/restricted/groupDelete.json | 9 + bin/plugin/restricted/realmCreate | 91 + bin/plugin/restricted/realmCreate.json | 3 + bin/plugin/restricted/realmDelete | 67 + bin/plugin/restricted/realmDelete.json | 8 + bin/plugin/restricted/realmInfo | 54 + bin/plugin/restricted/realmInfo.json | 7 + bin/plugin/restricted/realmList | 47 + bin/plugin/restricted/rootListIngressKeys | 46 + .../restricted/rootListIngressKeys.json | 5 + bin/plugin/restricted/selfAddPersonalAccess | 126 ++ .../restricted/selfAddPersonalAccess.json | 20 + bin/plugin/restricted/selfDelPersonalAccess | 85 + .../restricted/selfDelPersonalAccess.json | 13 + bin/plugin/restricted/whoHasAccessTo | 134 ++ bin/plugin/restricted/whoHasAccessTo.json | 10 + bin/proxy/osh-http-proxy-daemon | 91 + bin/proxy/osh-http-proxy-worker | 426 +++++ bin/shell/autologin | 111 ++ bin/shell/bastion-sync-helper.sh | 28 + bin/shell/connect.pl | 218 +++ bin/shell/osh.pl | 1471 +++++++++++++++++ bin/shell/pam_exec_pwd_info.sh | 17 + bin/sudogen/generate-sudoers.sh | 131 ++ .../libterm-readline-gnu-perl-jessiefix.patch | 14 + doc/CHANGELOG.md | 2 + doc/HIERARCHY.md | 33 + doc/UPGRADE_SPECIFIC.md | 3 + doc/VERSIONING.md | 29 + doc/sphinx-plugins-override/accountInfo.rst | 45 + .../groupCreate.override.rst | 55 + doc/sphinx-plugins-override/groupInfo.rst | 22 + doc/sphinx-plugins-override/help.rst | 16 + doc/sphinx-plugins-override/info.rst | 45 + doc/sphinx-plugins-override/lock.rst | 3 + doc/sphinx-plugins-override/nc.rst | 1 + doc/sphinx-plugins-override/scp.override.rst | 24 + .../selfGenerateEgressKey.override.rst | 43 + doc/sphinx/Makefile | 29 + doc/sphinx/_static/css/thebastion.css | 11 + doc/sphinx/build-plugins-help.sh | 85 + doc/sphinx/conf.py | 182 ++ doc/sphinx/faq.rst | 69 + doc/sphinx/index.rst | 51 + doc/sphinx/installation/advanced.rst | 290 ++++ doc/sphinx/installation/basic.rst | 163 ++ doc/sphinx/installation/docker.rst | 60 + doc/sphinx/installation/index.rst | 11 + doc/sphinx/installation/tests.rst | 34 + doc/sphinx/installation/upgrading.rst | 48 + doc/sphinx/plugins/admin/adminMaintenance.rst | 30 + doc/sphinx/plugins/admin/adminSudo.rst | 33 + doc/sphinx/plugins/admin/index.rst | 8 + .../group-aclkeeper/groupAddServer.rst | 72 + .../group-aclkeeper/groupDelServer.rst | 50 + doc/sphinx/plugins/group-aclkeeper/index.rst | 8 + .../group-gatekeeper/groupAddGuestAccess.rst | 70 + .../group-gatekeeper/groupAddMember.rst | 31 + .../group-gatekeeper/groupDelGuestAccess.rst | 62 + .../group-gatekeeper/groupDelMember.rst | 31 + .../groupListGuestAccesses.rst | 26 + doc/sphinx/plugins/group-gatekeeper/index.rst | 11 + .../plugins/group-owner/groupAddAclkeeper.rst | 28 + .../group-owner/groupAddGatekeeper.rst | 29 + .../plugins/group-owner/groupAddOwner.rst | 31 + .../plugins/group-owner/groupDelAclkeeper.rst | 28 + .../group-owner/groupDelGatekeeper.rst | 29 + .../plugins/group-owner/groupDelOwner.rst | 29 + .../group-owner/groupGeneratePassword.rst | 42 + .../plugins/group-owner/groupModify.rst | 31 + .../group-owner/groupTransmitOwnership.rst | 29 + doc/sphinx/plugins/group-owner/index.rst | 15 + doc/sphinx/plugins/index.rst | 12 + doc/sphinx/plugins/open/alive.rst | 25 + doc/sphinx/plugins/open/batch.rst | 39 + doc/sphinx/plugins/open/clush.rst | 38 + doc/sphinx/plugins/open/groupInfo.rst | 44 + doc/sphinx/plugins/open/groupList.rst | 22 + .../plugins/open/groupListPasswords.rst | 24 + doc/sphinx/plugins/open/groupListServers.rst | 26 + doc/sphinx/plugins/open/help.rst | 33 + doc/sphinx/plugins/open/index.rst | 37 + doc/sphinx/plugins/open/info.rst | 62 + doc/sphinx/plugins/open/lock.rst | 20 + doc/sphinx/plugins/open/mtr.rst | 22 + doc/sphinx/plugins/open/nc.rst | 31 + doc/sphinx/plugins/open/ping.rst | 34 + doc/sphinx/plugins/open/scp.rst | 28 + doc/sphinx/plugins/open/selfAddIngressKey.rst | 24 + doc/sphinx/plugins/open/selfDelIngressKey.rst | 28 + doc/sphinx/plugins/open/selfForgetHostKey.rst | 30 + .../plugins/open/selfGenerateEgressKey.rst | 47 + .../plugins/open/selfGeneratePassword.rst | 38 + .../open/selfGenerateProxyPassword.rst | 35 + doc/sphinx/plugins/open/selfListAccesses.rst | 28 + .../plugins/open/selfListEgressKeys.rst | 22 + .../plugins/open/selfListIngressKeys.rst | 21 + doc/sphinx/plugins/open/selfListPasswords.rst | 19 + doc/sphinx/plugins/open/selfListSessions.rst | 75 + .../plugins/open/selfMFAResetPassword.rst | 20 + doc/sphinx/plugins/open/selfMFAResetTOTP.rst | 20 + .../plugins/open/selfMFASetupPassword.rst | 22 + doc/sphinx/plugins/open/selfMFASetupTOTP.rst | 22 + doc/sphinx/plugins/open/selfPlaySession.rst | 22 + doc/sphinx/plugins/open/unlock.rst | 22 + .../restricted/accountAddPersonalAccess.rst | 65 + .../plugins/restricted/accountCreate.rst | 60 + .../restricted/accountDelPersonalAccess.rst | 50 + .../plugins/restricted/accountDelete.rst | 26 + .../restricted/accountGeneratePassword.rst | 42 + .../restricted/accountGrantCommand.rst | 33 + doc/sphinx/plugins/restricted/accountInfo.rst | 67 + doc/sphinx/plugins/restricted/accountList.rst | 30 + .../restricted/accountListAccesses.rst | 33 + .../restricted/accountListEgressKeys.rst | 27 + .../restricted/accountListIngressKeys.rst | 26 + .../restricted/accountListPasswords.rst | 24 + .../restricted/accountMFAResetPassword.rst | 24 + .../restricted/accountMFAResetTOTP.rst | 24 + .../plugins/restricted/accountModify.rst | 58 + doc/sphinx/plugins/restricted/accountPIV.rst | 37 + .../restricted/accountRevokeCommand.rst | 26 + .../plugins/restricted/accountUnexpire.rst | 25 + doc/sphinx/plugins/restricted/groupCreate.rst | 59 + doc/sphinx/plugins/restricted/groupDelete.rst | 26 + doc/sphinx/plugins/restricted/index.rst | 34 + doc/sphinx/plugins/restricted/realmCreate.rst | 36 + doc/sphinx/plugins/restricted/realmDelete.rst | 22 + doc/sphinx/plugins/restricted/realmInfo.rst | 22 + doc/sphinx/plugins/restricted/realmList.rst | 22 + .../restricted/rootListIngressKeys.rst | 21 + .../restricted/selfAddPersonalAccess.rst | 62 + .../restricted/selfDelPersonalAccess.rst | 46 + .../plugins/restricted/whoHasAccessTo.rst | 49 + doc/sphinx/presentation/features.rst | 6 + doc/sphinx/presentation/index.rst | 9 + doc/sphinx/presentation/principles.rst | 3 + doc/sphinx/presentation/security.rst | 52 + doc/sphinx/using/basics.rst | 251 +++ doc/sphinx/using/index.rst | 8 + docker/Dockerfile.centos7 | 29 + docker/Dockerfile.centos8 | 29 + docker/Dockerfile.debian10 | 32 + docker/Dockerfile.debian8 | 32 + docker/Dockerfile.debian9 | 32 + docker/Dockerfile.opensuse15 | 29 + docker/Dockerfile.opensuse151 | 29 + docker/Dockerfile.tester | 11 + docker/Dockerfile.ubuntu1404 | 32 + docker/Dockerfile.ubuntu1604 | 32 + docker/Dockerfile.ubuntu1804 | 32 + docker/Dockerfile.ubuntu2004 | 32 + docker/entrypoint.sh | 13 + etc/bastion/bastion.conf.dist | 365 ++++ etc/bastion/luks-config.sh.dist | 6 + etc/bastion/osh-backup-acl-keys.conf.dist | 30 + etc/bastion/osh-encrypt-rsync.conf.dist | 96 ++ etc/bastion/osh-http-proxy.conf.dist | 78 + etc/bastion/osh-piv-grace-reaper.conf.dist | 3 + etc/bastion/osh-sync-watcher.rsyncfilter.dist | 40 + etc/bastion/osh-sync-watcher.sh.dist | 40 + etc/cron.d/osh-backup-acl-keys.dist | 2 + etc/cron.d/osh-compress-old-logs.dist | 2 + etc/cron.d/osh-encrypt-rsync-ttyrec.dist | 2 + etc/cron.d/osh-lingering-sessions-reaper.dist | 2 + etc/cron.d/osh-orphaned-homedir.dist | 2 + etc/cron.d/osh-piv-grace-reaper.dist | 2 + etc/cron.d/osh-rotate-ttyrec.dist | 4 + etc/init.d/osh-http-proxy | 63 + etc/init.d/osh-sync-watcher | 63 + etc/logrotate.d/osh-backup-acl-keys.dist | 10 + etc/logrotate.d/osh-encrypt-rsync.dist | 10 + etc/logrotate.d/osh-http-proxy.dist | 10 + etc/logrotate.d/osh-sync-watcher.dist | 9 + etc/logrotate.d/osh-syslog.dist | 15 + etc/ovh-oco.d/bastion-code-works.conf.dist | 7 + .../root-connected-too-long.conf.dist | 4 + etc/pam.d/sshd.debian | 79 + etc/pam.d/sshd.rhel | 56 + etc/profile.d/luks-info.sh | 10 + etc/ssh/banner | 4 + etc/ssh/ssh_config.centos7 | 118 ++ etc/ssh/ssh_config.centos8 | 118 ++ etc/ssh/ssh_config.debian10 | 118 ++ etc/ssh/ssh_config.debian7 | 113 ++ etc/ssh/ssh_config.debian8 | 118 ++ etc/ssh/ssh_config.debian9 | 118 ++ etc/ssh/ssh_config.default | 114 ++ etc/ssh/ssh_config.opensuse15 | 119 ++ etc/ssh/sshd_config.centos7 | 139 ++ etc/ssh/sshd_config.centos8 | 139 ++ etc/ssh/sshd_config.debian10 | 143 ++ etc/ssh/sshd_config.debian7 | 125 ++ etc/ssh/sshd_config.debian8 | 146 ++ etc/ssh/sshd_config.debian9 | 146 ++ etc/ssh/sshd_config.default | 116 ++ etc/ssh/sshd_config.opensuse15 | 123 ++ .../100-header.sudoers | 1 + .../500-base.sudoers | 7 + etc/sudoers.d/osh-bastion-config | 7 + etc/sudoers.d/osh-bastion-http-proxy | 3 + etc/sudoers.d/osh-bastion-sync | 1 + etc/sudoers.d/osh-plugin-accountCreate | 1 + etc/sudoers.d/osh-plugin-accountDelete | 1 + .../osh-plugin-accountGeneratePassword | 2 + .../osh-plugin-accountGetPasswordInfo | 1 + .../osh-plugin-accountListEgressKeys | 1 + .../osh-plugin-accountListIngressKeys | 1 + etc/sudoers.d/osh-plugin-accountListPasswords | 1 + .../osh-plugin-accountMFAResetPassword | 1 + etc/sudoers.d/osh-plugin-accountMFAResetTOTP | 1 + etc/sudoers.d/osh-plugin-accountModify | 2 + etc/sudoers.d/osh-plugin-accountModifyCommand | 4 + .../osh-plugin-accountModifyPersonalAccess | 5 + etc/sudoers.d/osh-plugin-accountPIV | 3 + etc/sudoers.d/osh-plugin-accountUnexpire | 1 + etc/sudoers.d/osh-plugin-adminMaintenance | 2 + etc/sudoers.d/osh-plugin-adminSudo | 1 + etc/sudoers.d/osh-plugin-groupCreate | 1 + etc/sudoers.d/osh-plugin-groupDelete | 1 + etc/sudoers.d/osh-plugin-realmCreate | 1 + etc/sudoers.d/osh-plugin-rootListIngressKeys | 1 + .../100-header.sudoers | 1 + etc/sudoers.group.template.d/500-base.sudoers | 27 + etc/syslog-ng/conf.d/20-bastion.conf.dist | 104 ++ etc/systemd/osh-http-proxy.service | 13 + etc/systemd/osh-sync-watcher.service | 11 + lib/perl/OVH/Bastion.pm | 923 +++++++++++ lib/perl/OVH/Bastion/Plugin.pm | 144 ++ .../OVH/Bastion/Plugin/generatePassword.pm | 195 +++ lib/perl/OVH/Bastion/Plugin/groupSetRole.pm | 314 ++++ lib/perl/OVH/Bastion/ProxyHTTP.pm | 500 ++++++ lib/perl/OVH/Bastion/allowdeny.inc | 1181 +++++++++++++ lib/perl/OVH/Bastion/allowkeeper.inc | 1002 +++++++++++ lib/perl/OVH/Bastion/configuration.inc | 536 ++++++ lib/perl/OVH/Bastion/execute.inc | 393 +++++ lib/perl/OVH/Bastion/interactive.inc | 231 +++ lib/perl/OVH/Bastion/jail.inc | 115 ++ lib/perl/OVH/Bastion/log.inc | 802 +++++++++ lib/perl/OVH/Bastion/mock.inc | 80 + lib/perl/OVH/Bastion/os.inc | 575 +++++++ lib/perl/OVH/Bastion/password.inc | 156 ++ lib/perl/OVH/Bastion/ssh.inc | 823 +++++++++ lib/perl/OVH/Result.pm | 77 + lib/perl/OVH/SimpleLog.pm | 123 ++ lib/shell/colors.inc | 17 + lib/shell/functions.inc | 288 ++++ .../docker/docker_build_and_run_tests.sh | 166 ++ .../docker/docker_build_and_run_tests_all.sh | 54 + tests/functional/docker/target_role.sh | 116 ++ tests/functional/docker/tester_role.sh | 60 + tests/functional/fake_ttyrec.sh | 13 + tests/functional/launch_tests_on_instance.sh | 440 +++++ tests/functional/tests.d/300-activeness.sh | 57 + tests/functional/tests.d/310-realm.sh | 279 ++++ tests/functional/tests.d/320-base.sh | 38 + tests/functional/tests.d/330-selfkeys.sh | 620 +++++++ tests/functional/tests.d/340-selfaccesses.sh | 490 ++++++ tests/functional/tests.d/350-groups.sh | 1101 ++++++++++++ tests/functional/tests.d/360-plugins.sh | 104 ++ tests/functional/tests.d/370-mfa.sh | 383 +++++ tests/functional/tests.d/dummy | 1 + tests/unit/run.pl | 91 + 463 files changed, 39401 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/documentation.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 AUTHORS create mode 100644 CONTRIBUTING.md create mode 100644 CONTRIBUTORS create mode 100644 DESIGN.md create mode 100644 LICENSE create mode 100644 MAINTAINERS create mode 100644 README.md create mode 100755 bin/admin/build-and-install-ttyrec.sh create mode 100755 bin/admin/check-consistency.pl create mode 100755 bin/admin/check-ssh-hardening.pl create mode 100755 bin/admin/fix-group-gid.sh create mode 100755 bin/admin/fixrights.sh create mode 100755 bin/admin/grant-all-restricted-commands-to.sh create mode 100755 bin/admin/install create mode 100755 bin/admin/osh-sync-watcher.sh create mode 100755 bin/admin/packages-check.sh create mode 100755 bin/admin/rename-group.sh create mode 100755 bin/admin/restore-account.sh create mode 100755 bin/admin/setup-encryption.sh create mode 100755 bin/admin/setup-first-admin-account.sh create mode 100755 bin/admin/setup-gpg.sh create mode 100755 bin/admin/unlock-home.sh create mode 100755 bin/cron/osh-backup-acl-keys.sh create mode 100755 bin/cron/osh-compress-old-logs.sh create mode 100755 bin/cron/osh-encrypt-rsync.pl create mode 100755 bin/cron/osh-lingering-sessions-reaper.sh create mode 100755 bin/cron/osh-orphaned-homedir.sh create mode 100755 bin/cron/osh-piv-grace-reaper.pl create mode 100755 bin/cron/osh-rotate-ttyrec.sh create mode 100755 bin/dev/debug_toggle.sh create mode 100755 bin/dev/perl-check.sh create mode 100755 bin/dev/perl-critic.sh create mode 100755 bin/dev/perl-tidy.sh create mode 100755 bin/dev/perl-use-all.pl create mode 100644 bin/dev/perlcriticrc create mode 100755 bin/dev/shell-check.sh create mode 100755 bin/helper/osh-accountAddGroupServer create mode 100755 bin/helper/osh-accountCreate create mode 100755 bin/helper/osh-accountDelete create mode 100755 bin/helper/osh-accountGeneratePassword create mode 100755 bin/helper/osh-accountGetPasswordInfo create mode 100755 bin/helper/osh-accountListEgressKeys create mode 100755 bin/helper/osh-accountListIngressKeys create mode 100755 bin/helper/osh-accountListPasswords create mode 100755 bin/helper/osh-accountMFAResetPassword create mode 100755 bin/helper/osh-accountMFAResetTOTP create mode 100755 bin/helper/osh-accountModify create mode 100755 bin/helper/osh-accountModifyCommand create mode 100755 bin/helper/osh-accountModifyPersonalAccess create mode 100755 bin/helper/osh-accountPIV create mode 100755 bin/helper/osh-accountUnexpire create mode 100755 bin/helper/osh-adminMaintenance create mode 100755 bin/helper/osh-groupAddServer create mode 100755 bin/helper/osh-groupAddSymlinkToAccount create mode 100755 bin/helper/osh-groupCreate create mode 100755 bin/helper/osh-groupDelete create mode 100755 bin/helper/osh-groupGeneratePassword create mode 100755 bin/helper/osh-groupModify create mode 100755 bin/helper/osh-groupSetRole create mode 100755 bin/helper/osh-selfMFASetupPassword create mode 100755 bin/helper/osh-selfMFASetupTOTP create mode 100755 bin/other/check-active-account-fortestsonly.pl create mode 100755 bin/other/check-active-account-simple.pl create mode 100755 bin/plugin/admin/adminMaintenance create mode 100644 bin/plugin/admin/adminMaintenance.json create mode 100755 bin/plugin/admin/adminSudo create mode 100644 bin/plugin/admin/adminSudo.json create mode 100755 bin/plugin/group-aclkeeper/groupAddServer create mode 100644 bin/plugin/group-aclkeeper/groupAddServer.json create mode 100755 bin/plugin/group-aclkeeper/groupDelServer create mode 100644 bin/plugin/group-aclkeeper/groupDelServer.json create mode 100755 bin/plugin/group-gatekeeper/groupAddGuestAccess create mode 100644 bin/plugin/group-gatekeeper/groupAddGuestAccess.json create mode 100755 bin/plugin/group-gatekeeper/groupAddMember create mode 100644 bin/plugin/group-gatekeeper/groupAddMember.json create mode 100755 bin/plugin/group-gatekeeper/groupDelGuestAccess create mode 100644 bin/plugin/group-gatekeeper/groupDelGuestAccess.json create mode 100755 bin/plugin/group-gatekeeper/groupDelMember create mode 100644 bin/plugin/group-gatekeeper/groupDelMember.json create mode 100755 bin/plugin/group-gatekeeper/groupListGuestAccesses create mode 100644 bin/plugin/group-gatekeeper/groupListGuestAccesses.json create mode 100755 bin/plugin/group-owner/groupAddAclkeeper create mode 100644 bin/plugin/group-owner/groupAddAclkeeper.json create mode 100755 bin/plugin/group-owner/groupAddGatekeeper create mode 100644 bin/plugin/group-owner/groupAddGatekeeper.json create mode 100755 bin/plugin/group-owner/groupAddOwner create mode 100644 bin/plugin/group-owner/groupAddOwner.json create mode 100755 bin/plugin/group-owner/groupDelAclkeeper create mode 100644 bin/plugin/group-owner/groupDelAclkeeper.json create mode 100755 bin/plugin/group-owner/groupDelGatekeeper create mode 100644 bin/plugin/group-owner/groupDelGatekeeper.json create mode 100755 bin/plugin/group-owner/groupDelOwner create mode 100644 bin/plugin/group-owner/groupDelOwner.json create mode 100755 bin/plugin/group-owner/groupGeneratePassword create mode 100644 bin/plugin/group-owner/groupGeneratePassword.json create mode 100755 bin/plugin/group-owner/groupModify create mode 100644 bin/plugin/group-owner/groupModify.json create mode 100755 bin/plugin/group-owner/groupTransmitOwnership create mode 100644 bin/plugin/group-owner/groupTransmitOwnership.json create mode 100755 bin/plugin/open/alive create mode 100644 bin/plugin/open/alive.json create mode 100755 bin/plugin/open/batch create mode 100755 bin/plugin/open/clush create mode 100755 bin/plugin/open/groupInfo create mode 100644 bin/plugin/open/groupInfo.json create mode 100755 bin/plugin/open/groupList create mode 100644 bin/plugin/open/groupList.json create mode 100755 bin/plugin/open/groupListPasswords create mode 100644 bin/plugin/open/groupListPasswords.json create mode 100755 bin/plugin/open/groupListServers create mode 100644 bin/plugin/open/groupListServers.json create mode 100755 bin/plugin/open/help create mode 100644 bin/plugin/open/help.json create mode 100755 bin/plugin/open/info create mode 100644 bin/plugin/open/info.json create mode 100755 bin/plugin/open/lock create mode 100644 bin/plugin/open/lock.json create mode 100755 bin/plugin/open/mtr create mode 100644 bin/plugin/open/mtr.json create mode 100755 bin/plugin/open/nc create mode 100644 bin/plugin/open/nc.json create mode 100755 bin/plugin/open/ping create mode 100644 bin/plugin/open/ping.json create mode 100755 bin/plugin/open/scp create mode 100644 bin/plugin/open/scp.json create mode 100755 bin/plugin/open/selfAddIngressKey create mode 100644 bin/plugin/open/selfAddIngressKey.json create mode 100755 bin/plugin/open/selfDelIngressKey create mode 100644 bin/plugin/open/selfDelIngressKey.json create mode 100755 bin/plugin/open/selfForgetHostKey create mode 100644 bin/plugin/open/selfForgetHostKey.json create mode 100755 bin/plugin/open/selfGenerateEgressKey create mode 100644 bin/plugin/open/selfGenerateEgressKey.json create mode 100755 bin/plugin/open/selfGeneratePassword create mode 100644 bin/plugin/open/selfGeneratePassword.json create mode 100755 bin/plugin/open/selfGenerateProxyPassword create mode 100644 bin/plugin/open/selfGenerateProxyPassword.json create mode 100755 bin/plugin/open/selfListAccesses create mode 100644 bin/plugin/open/selfListAccesses.json create mode 100755 bin/plugin/open/selfListEgressKeys create mode 100755 bin/plugin/open/selfListIngressKeys create mode 100644 bin/plugin/open/selfListIngressKeys.json create mode 100755 bin/plugin/open/selfListPasswords create mode 100644 bin/plugin/open/selfListPasswords.json create mode 100755 bin/plugin/open/selfListSessions create mode 100755 bin/plugin/open/selfMFAResetPassword create mode 100644 bin/plugin/open/selfMFAResetPassword.json create mode 100755 bin/plugin/open/selfMFAResetTOTP create mode 100644 bin/plugin/open/selfMFAResetTOTP.json create mode 100755 bin/plugin/open/selfMFASetupPassword create mode 100644 bin/plugin/open/selfMFASetupPassword.json create mode 100755 bin/plugin/open/selfMFASetupTOTP create mode 100644 bin/plugin/open/selfMFASetupTOTP.json create mode 100755 bin/plugin/open/selfPlaySession create mode 100644 bin/plugin/open/selfPlaySession.json create mode 100755 bin/plugin/open/unlock create mode 100644 bin/plugin/open/unlock.json create mode 100755 bin/plugin/restricted/accountAddPersonalAccess create mode 100644 bin/plugin/restricted/accountAddPersonalAccess.json create mode 100755 bin/plugin/restricted/accountCreate create mode 100644 bin/plugin/restricted/accountCreate.json create mode 100755 bin/plugin/restricted/accountDelPersonalAccess create mode 100644 bin/plugin/restricted/accountDelPersonalAccess.json create mode 100755 bin/plugin/restricted/accountDelete create mode 100644 bin/plugin/restricted/accountDelete.json create mode 100755 bin/plugin/restricted/accountGeneratePassword create mode 100644 bin/plugin/restricted/accountGeneratePassword.json create mode 100755 bin/plugin/restricted/accountGrantCommand create mode 100644 bin/plugin/restricted/accountGrantCommand.json create mode 100755 bin/plugin/restricted/accountInfo create mode 100644 bin/plugin/restricted/accountInfo.json create mode 100755 bin/plugin/restricted/accountList create mode 100644 bin/plugin/restricted/accountList.json create mode 100755 bin/plugin/restricted/accountListAccesses create mode 100644 bin/plugin/restricted/accountListAccesses.json create mode 100755 bin/plugin/restricted/accountListEgressKeys create mode 100644 bin/plugin/restricted/accountListEgressKeys.json create mode 100755 bin/plugin/restricted/accountListIngressKeys create mode 100644 bin/plugin/restricted/accountListIngressKeys.json create mode 100755 bin/plugin/restricted/accountListPasswords create mode 100644 bin/plugin/restricted/accountListPasswords.json create mode 100755 bin/plugin/restricted/accountMFAResetPassword create mode 100644 bin/plugin/restricted/accountMFAResetPassword.json create mode 100755 bin/plugin/restricted/accountMFAResetTOTP create mode 100644 bin/plugin/restricted/accountMFAResetTOTP.json create mode 100755 bin/plugin/restricted/accountModify create mode 100644 bin/plugin/restricted/accountModify.json create mode 100755 bin/plugin/restricted/accountPIV create mode 100644 bin/plugin/restricted/accountPIV.json create mode 100755 bin/plugin/restricted/accountRevokeCommand create mode 100644 bin/plugin/restricted/accountRevokeCommand.json create mode 100755 bin/plugin/restricted/accountUnexpire create mode 100644 bin/plugin/restricted/accountUnexpire.json create mode 100755 bin/plugin/restricted/groupCreate create mode 100644 bin/plugin/restricted/groupCreate.json create mode 100755 bin/plugin/restricted/groupDelete create mode 100644 bin/plugin/restricted/groupDelete.json create mode 100755 bin/plugin/restricted/realmCreate create mode 100644 bin/plugin/restricted/realmCreate.json create mode 100755 bin/plugin/restricted/realmDelete create mode 100644 bin/plugin/restricted/realmDelete.json create mode 100755 bin/plugin/restricted/realmInfo create mode 100644 bin/plugin/restricted/realmInfo.json create mode 100755 bin/plugin/restricted/realmList create mode 100755 bin/plugin/restricted/rootListIngressKeys create mode 100644 bin/plugin/restricted/rootListIngressKeys.json create mode 100755 bin/plugin/restricted/selfAddPersonalAccess create mode 100644 bin/plugin/restricted/selfAddPersonalAccess.json create mode 100755 bin/plugin/restricted/selfDelPersonalAccess create mode 100644 bin/plugin/restricted/selfDelPersonalAccess.json create mode 100755 bin/plugin/restricted/whoHasAccessTo create mode 100644 bin/plugin/restricted/whoHasAccessTo.json create mode 100755 bin/proxy/osh-http-proxy-daemon create mode 100755 bin/proxy/osh-http-proxy-worker create mode 100755 bin/shell/autologin create mode 100755 bin/shell/bastion-sync-helper.sh create mode 100755 bin/shell/connect.pl create mode 100755 bin/shell/osh.pl create mode 100755 bin/shell/pam_exec_pwd_info.sh create mode 100755 bin/sudogen/generate-sudoers.sh create mode 100644 contrib/libterm-readline-gnu-perl-jessiefix.patch create mode 100644 doc/CHANGELOG.md create mode 100644 doc/HIERARCHY.md create mode 100644 doc/UPGRADE_SPECIFIC.md create mode 100644 doc/VERSIONING.md create mode 100644 doc/sphinx-plugins-override/accountInfo.rst create mode 100644 doc/sphinx-plugins-override/groupCreate.override.rst create mode 100644 doc/sphinx-plugins-override/groupInfo.rst create mode 100644 doc/sphinx-plugins-override/help.rst create mode 100644 doc/sphinx-plugins-override/info.rst create mode 100644 doc/sphinx-plugins-override/lock.rst create mode 100644 doc/sphinx-plugins-override/nc.rst create mode 100644 doc/sphinx-plugins-override/scp.override.rst create mode 100644 doc/sphinx-plugins-override/selfGenerateEgressKey.override.rst create mode 100644 doc/sphinx/Makefile create mode 100644 doc/sphinx/_static/css/thebastion.css create mode 100644 doc/sphinx/build-plugins-help.sh create mode 100644 doc/sphinx/conf.py create mode 100644 doc/sphinx/faq.rst create mode 100644 doc/sphinx/index.rst create mode 100644 doc/sphinx/installation/advanced.rst create mode 100644 doc/sphinx/installation/basic.rst create mode 100644 doc/sphinx/installation/docker.rst create mode 100644 doc/sphinx/installation/index.rst create mode 100644 doc/sphinx/installation/tests.rst create mode 100644 doc/sphinx/installation/upgrading.rst create mode 100644 doc/sphinx/plugins/admin/adminMaintenance.rst create mode 100644 doc/sphinx/plugins/admin/adminSudo.rst create mode 100644 doc/sphinx/plugins/admin/index.rst create mode 100644 doc/sphinx/plugins/group-aclkeeper/groupAddServer.rst create mode 100644 doc/sphinx/plugins/group-aclkeeper/groupDelServer.rst create mode 100644 doc/sphinx/plugins/group-aclkeeper/index.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/groupAddGuestAccess.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/groupAddMember.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/groupDelGuestAccess.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/groupDelMember.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/groupListGuestAccesses.rst create mode 100644 doc/sphinx/plugins/group-gatekeeper/index.rst create mode 100644 doc/sphinx/plugins/group-owner/groupAddAclkeeper.rst create mode 100644 doc/sphinx/plugins/group-owner/groupAddGatekeeper.rst create mode 100644 doc/sphinx/plugins/group-owner/groupAddOwner.rst create mode 100644 doc/sphinx/plugins/group-owner/groupDelAclkeeper.rst create mode 100644 doc/sphinx/plugins/group-owner/groupDelGatekeeper.rst create mode 100644 doc/sphinx/plugins/group-owner/groupDelOwner.rst create mode 100644 doc/sphinx/plugins/group-owner/groupGeneratePassword.rst create mode 100644 doc/sphinx/plugins/group-owner/groupModify.rst create mode 100644 doc/sphinx/plugins/group-owner/groupTransmitOwnership.rst create mode 100644 doc/sphinx/plugins/group-owner/index.rst create mode 100644 doc/sphinx/plugins/index.rst create mode 100644 doc/sphinx/plugins/open/alive.rst create mode 100644 doc/sphinx/plugins/open/batch.rst create mode 100644 doc/sphinx/plugins/open/clush.rst create mode 100644 doc/sphinx/plugins/open/groupInfo.rst create mode 100644 doc/sphinx/plugins/open/groupList.rst create mode 100644 doc/sphinx/plugins/open/groupListPasswords.rst create mode 100644 doc/sphinx/plugins/open/groupListServers.rst create mode 100644 doc/sphinx/plugins/open/help.rst create mode 100644 doc/sphinx/plugins/open/index.rst create mode 100644 doc/sphinx/plugins/open/info.rst create mode 100644 doc/sphinx/plugins/open/lock.rst create mode 100644 doc/sphinx/plugins/open/mtr.rst create mode 100644 doc/sphinx/plugins/open/nc.rst create mode 100644 doc/sphinx/plugins/open/ping.rst create mode 100644 doc/sphinx/plugins/open/scp.rst create mode 100644 doc/sphinx/plugins/open/selfAddIngressKey.rst create mode 100644 doc/sphinx/plugins/open/selfDelIngressKey.rst create mode 100644 doc/sphinx/plugins/open/selfForgetHostKey.rst create mode 100644 doc/sphinx/plugins/open/selfGenerateEgressKey.rst create mode 100644 doc/sphinx/plugins/open/selfGeneratePassword.rst create mode 100644 doc/sphinx/plugins/open/selfGenerateProxyPassword.rst create mode 100644 doc/sphinx/plugins/open/selfListAccesses.rst create mode 100644 doc/sphinx/plugins/open/selfListEgressKeys.rst create mode 100644 doc/sphinx/plugins/open/selfListIngressKeys.rst create mode 100644 doc/sphinx/plugins/open/selfListPasswords.rst create mode 100644 doc/sphinx/plugins/open/selfListSessions.rst create mode 100644 doc/sphinx/plugins/open/selfMFAResetPassword.rst create mode 100644 doc/sphinx/plugins/open/selfMFAResetTOTP.rst create mode 100644 doc/sphinx/plugins/open/selfMFASetupPassword.rst create mode 100644 doc/sphinx/plugins/open/selfMFASetupTOTP.rst create mode 100644 doc/sphinx/plugins/open/selfPlaySession.rst create mode 100644 doc/sphinx/plugins/open/unlock.rst create mode 100644 doc/sphinx/plugins/restricted/accountAddPersonalAccess.rst create mode 100644 doc/sphinx/plugins/restricted/accountCreate.rst create mode 100644 doc/sphinx/plugins/restricted/accountDelPersonalAccess.rst create mode 100644 doc/sphinx/plugins/restricted/accountDelete.rst create mode 100644 doc/sphinx/plugins/restricted/accountGeneratePassword.rst create mode 100644 doc/sphinx/plugins/restricted/accountGrantCommand.rst create mode 100644 doc/sphinx/plugins/restricted/accountInfo.rst create mode 100644 doc/sphinx/plugins/restricted/accountList.rst create mode 100644 doc/sphinx/plugins/restricted/accountListAccesses.rst create mode 100644 doc/sphinx/plugins/restricted/accountListEgressKeys.rst create mode 100644 doc/sphinx/plugins/restricted/accountListIngressKeys.rst create mode 100644 doc/sphinx/plugins/restricted/accountListPasswords.rst create mode 100644 doc/sphinx/plugins/restricted/accountMFAResetPassword.rst create mode 100644 doc/sphinx/plugins/restricted/accountMFAResetTOTP.rst create mode 100644 doc/sphinx/plugins/restricted/accountModify.rst create mode 100644 doc/sphinx/plugins/restricted/accountPIV.rst create mode 100644 doc/sphinx/plugins/restricted/accountRevokeCommand.rst create mode 100644 doc/sphinx/plugins/restricted/accountUnexpire.rst create mode 100644 doc/sphinx/plugins/restricted/groupCreate.rst create mode 100644 doc/sphinx/plugins/restricted/groupDelete.rst create mode 100644 doc/sphinx/plugins/restricted/index.rst create mode 100644 doc/sphinx/plugins/restricted/realmCreate.rst create mode 100644 doc/sphinx/plugins/restricted/realmDelete.rst create mode 100644 doc/sphinx/plugins/restricted/realmInfo.rst create mode 100644 doc/sphinx/plugins/restricted/realmList.rst create mode 100644 doc/sphinx/plugins/restricted/rootListIngressKeys.rst create mode 100644 doc/sphinx/plugins/restricted/selfAddPersonalAccess.rst create mode 100644 doc/sphinx/plugins/restricted/selfDelPersonalAccess.rst create mode 100644 doc/sphinx/plugins/restricted/whoHasAccessTo.rst create mode 100644 doc/sphinx/presentation/features.rst create mode 100644 doc/sphinx/presentation/index.rst create mode 100644 doc/sphinx/presentation/principles.rst create mode 100644 doc/sphinx/presentation/security.rst create mode 100644 doc/sphinx/using/basics.rst create mode 100644 doc/sphinx/using/index.rst create mode 100644 docker/Dockerfile.centos7 create mode 100644 docker/Dockerfile.centos8 create mode 100644 docker/Dockerfile.debian10 create mode 100644 docker/Dockerfile.debian8 create mode 100644 docker/Dockerfile.debian9 create mode 100644 docker/Dockerfile.opensuse15 create mode 100644 docker/Dockerfile.opensuse151 create mode 100644 docker/Dockerfile.tester create mode 100644 docker/Dockerfile.ubuntu1404 create mode 100644 docker/Dockerfile.ubuntu1604 create mode 100644 docker/Dockerfile.ubuntu1804 create mode 100644 docker/Dockerfile.ubuntu2004 create mode 100755 docker/entrypoint.sh create mode 100644 etc/bastion/bastion.conf.dist create mode 100644 etc/bastion/luks-config.sh.dist create mode 100644 etc/bastion/osh-backup-acl-keys.conf.dist create mode 100644 etc/bastion/osh-encrypt-rsync.conf.dist create mode 100644 etc/bastion/osh-http-proxy.conf.dist create mode 100644 etc/bastion/osh-piv-grace-reaper.conf.dist create mode 100644 etc/bastion/osh-sync-watcher.rsyncfilter.dist create mode 100644 etc/bastion/osh-sync-watcher.sh.dist create mode 100644 etc/cron.d/osh-backup-acl-keys.dist create mode 100644 etc/cron.d/osh-compress-old-logs.dist create mode 100644 etc/cron.d/osh-encrypt-rsync-ttyrec.dist create mode 100644 etc/cron.d/osh-lingering-sessions-reaper.dist create mode 100644 etc/cron.d/osh-orphaned-homedir.dist create mode 100644 etc/cron.d/osh-piv-grace-reaper.dist create mode 100644 etc/cron.d/osh-rotate-ttyrec.dist create mode 100644 etc/init.d/osh-http-proxy create mode 100644 etc/init.d/osh-sync-watcher create mode 100644 etc/logrotate.d/osh-backup-acl-keys.dist create mode 100644 etc/logrotate.d/osh-encrypt-rsync.dist create mode 100644 etc/logrotate.d/osh-http-proxy.dist create mode 100644 etc/logrotate.d/osh-sync-watcher.dist create mode 100644 etc/logrotate.d/osh-syslog.dist create mode 100644 etc/ovh-oco.d/bastion-code-works.conf.dist create mode 100644 etc/ovh-oco.d/root-connected-too-long.conf.dist create mode 100644 etc/pam.d/sshd.debian create mode 100644 etc/pam.d/sshd.rhel create mode 100644 etc/profile.d/luks-info.sh create mode 100644 etc/ssh/banner create mode 100644 etc/ssh/ssh_config.centos7 create mode 100644 etc/ssh/ssh_config.centos8 create mode 100644 etc/ssh/ssh_config.debian10 create mode 100644 etc/ssh/ssh_config.debian7 create mode 100644 etc/ssh/ssh_config.debian8 create mode 100644 etc/ssh/ssh_config.debian9 create mode 100644 etc/ssh/ssh_config.default create mode 100644 etc/ssh/ssh_config.opensuse15 create mode 100644 etc/ssh/sshd_config.centos7 create mode 100644 etc/ssh/sshd_config.centos8 create mode 100644 etc/ssh/sshd_config.debian10 create mode 100644 etc/ssh/sshd_config.debian7 create mode 100644 etc/ssh/sshd_config.debian8 create mode 100644 etc/ssh/sshd_config.debian9 create mode 100644 etc/ssh/sshd_config.default create mode 100644 etc/ssh/sshd_config.opensuse15 create mode 100644 etc/sudoers.account.template.d/100-header.sudoers create mode 100644 etc/sudoers.account.template.d/500-base.sudoers create mode 100644 etc/sudoers.d/osh-bastion-config create mode 100644 etc/sudoers.d/osh-bastion-http-proxy create mode 100644 etc/sudoers.d/osh-bastion-sync create mode 100644 etc/sudoers.d/osh-plugin-accountCreate create mode 100644 etc/sudoers.d/osh-plugin-accountDelete create mode 100644 etc/sudoers.d/osh-plugin-accountGeneratePassword create mode 100644 etc/sudoers.d/osh-plugin-accountGetPasswordInfo create mode 100644 etc/sudoers.d/osh-plugin-accountListEgressKeys create mode 100644 etc/sudoers.d/osh-plugin-accountListIngressKeys create mode 100644 etc/sudoers.d/osh-plugin-accountListPasswords create mode 100644 etc/sudoers.d/osh-plugin-accountMFAResetPassword create mode 100644 etc/sudoers.d/osh-plugin-accountMFAResetTOTP create mode 100644 etc/sudoers.d/osh-plugin-accountModify create mode 100644 etc/sudoers.d/osh-plugin-accountModifyCommand create mode 100644 etc/sudoers.d/osh-plugin-accountModifyPersonalAccess create mode 100644 etc/sudoers.d/osh-plugin-accountPIV create mode 100644 etc/sudoers.d/osh-plugin-accountUnexpire create mode 100644 etc/sudoers.d/osh-plugin-adminMaintenance create mode 100644 etc/sudoers.d/osh-plugin-adminSudo create mode 100644 etc/sudoers.d/osh-plugin-groupCreate create mode 100644 etc/sudoers.d/osh-plugin-groupDelete create mode 100644 etc/sudoers.d/osh-plugin-realmCreate create mode 100644 etc/sudoers.d/osh-plugin-rootListIngressKeys create mode 100644 etc/sudoers.group.template.d/100-header.sudoers create mode 100644 etc/sudoers.group.template.d/500-base.sudoers create mode 100644 etc/syslog-ng/conf.d/20-bastion.conf.dist create mode 100644 etc/systemd/osh-http-proxy.service create mode 100644 etc/systemd/osh-sync-watcher.service create mode 100644 lib/perl/OVH/Bastion.pm create mode 100644 lib/perl/OVH/Bastion/Plugin.pm create mode 100644 lib/perl/OVH/Bastion/Plugin/generatePassword.pm create mode 100644 lib/perl/OVH/Bastion/Plugin/groupSetRole.pm create mode 100644 lib/perl/OVH/Bastion/ProxyHTTP.pm create mode 100644 lib/perl/OVH/Bastion/allowdeny.inc create mode 100644 lib/perl/OVH/Bastion/allowkeeper.inc create mode 100644 lib/perl/OVH/Bastion/configuration.inc create mode 100644 lib/perl/OVH/Bastion/execute.inc create mode 100644 lib/perl/OVH/Bastion/interactive.inc create mode 100644 lib/perl/OVH/Bastion/jail.inc create mode 100644 lib/perl/OVH/Bastion/log.inc create mode 100644 lib/perl/OVH/Bastion/mock.inc create mode 100644 lib/perl/OVH/Bastion/os.inc create mode 100644 lib/perl/OVH/Bastion/password.inc create mode 100644 lib/perl/OVH/Bastion/ssh.inc create mode 100644 lib/perl/OVH/Result.pm create mode 100644 lib/perl/OVH/SimpleLog.pm create mode 100644 lib/shell/colors.inc create mode 100644 lib/shell/functions.inc create mode 100755 tests/functional/docker/docker_build_and_run_tests.sh create mode 100755 tests/functional/docker/docker_build_and_run_tests_all.sh create mode 100755 tests/functional/docker/target_role.sh create mode 100755 tests/functional/docker/tester_role.sh create mode 100755 tests/functional/fake_ttyrec.sh create mode 100755 tests/functional/launch_tests_on_instance.sh create mode 100644 tests/functional/tests.d/300-activeness.sh create mode 100644 tests/functional/tests.d/310-realm.sh create mode 100644 tests/functional/tests.d/320-base.sh create mode 100644 tests/functional/tests.d/330-selfkeys.sh create mode 100644 tests/functional/tests.d/340-selfaccesses.sh create mode 100644 tests/functional/tests.d/350-groups.sh create mode 100644 tests/functional/tests.d/360-plugins.sh create mode 100644 tests/functional/tests.d/370-mfa.sh create mode 100644 tests/functional/tests.d/dummy create mode 100755 tests/unit/run.pl diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d67d10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +doc +docs +*.tar.gz diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000..ccd893f --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,32 @@ +name: documentation + +on: + push: + branches: + - master + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v2 + - + name: Install sphinx and prerequisites + run: | + sudo apt update + sudo apt install -y python3-sphinx-rtd-theme python3-sphinx make libcommon-sense-perl libjson-perl + - + name: Build documentation + run: cd doc/sphinx/ && make all + - + name: Deploy to GitHub Pages + if: success() + uses: crazy-max/ghaction-github-pages@v2 + with: + target_branch: gh-pages + build_dir: docs + allow_empty_commit: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4cfff5b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Linux distros tests + +on: + pull_request: + types: [labeled, synchronize] + +jobs: + tests_full: + strategy: + matrix: + platform: [centos7, centos8, debian10, debian8, debian9, opensuse15, opensuse151, ubuntu1404, ubuntu1604, ubuntu1804, ubuntu2004] + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'tests:full') + steps: + - uses: actions/checkout@v2 + - name: run tests inside a ${{ matrix.platform }} docker + run: tests/functional/docker/docker_build_and_run_tests.sh ${{ matrix.platform }} + env: + DOCKER_TTY: false + + tests_short: + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'tests:short') + steps: + - uses: actions/checkout@v2 + - name: run tests inside a debian10 docker + run: tests/functional/docker/docker_build_and_run_tests.sh debian10 + env: + DOCKER_TTY: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca9d8af --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +doc/sphinx/_build diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..396185c --- /dev/null +++ b/AUTHORS @@ -0,0 +1,12 @@ +# This is the official list of OVH::Bastion authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS files +# and it lists the copyright holders only. + +# Names should be added to this file as one of +# Organization's name +# Individual's name +# Individual's name + +# Please keep the list sorted. + +OVH SAS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6ba6a0f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing to The Bastion + +This project accepts contributions. In order to contribute, you should +pay attention to a few things: + +1. your code must follow the The Bastion design choices, see DESIGN.md +2. your code must follow the coding style rules +3. your code must be added to the unit and/or integration tests where applicable +4. your code must be documented +5. your work must be signed (see below) +6. you may contribute through GitHub Pull Requests + +# Coding and documentation Style for source code + +- All languages + - Code must be indented with 4-spaces, no tabs. Vim modelines are present + in all source files, so if you use vim, you should be good to go +- Perl + - Code must be tidy (see `bin/dev/perl-tidy.sh`) + - Code must not raise any perlcritic warning (see `bin/dev/perl-critic.sh`) + - One must refrain using any non-core Perl module (check `corelist`) + - If not possible, the module should be packaged at least under Debian, + all supported versions, and available at least in trusted third party + repositories on other supported OSes. No `cpan install`. +- POSIX shell and Bash + - Code must not raise any shellcheck warning (see `bin/dev/shell-check.sh`) + +# Submitting Modifications + +The contributions should be submitted through Github Pull Requests +and follow the DCO which is defined below. + +# Licensing for new files + +The Bastion is licensed under the Apache License 2.0. Anything +contributed to The Bastion must be released under this license. + +When introducing a new file into the project, please make sure it has a +copyright header making clear under which license it's being released. + +# Developer Certificate of Origin (DCO) + +To improve tracking of contributions to this project we will use a +process modeled on the modified DCO 1.1 and use a "sign-off" procedure +on patches that are being emailed around or contributed in any other +way. + +The sign-off is a simple line at the end of the explanation for the +patch, which certifies that you wrote it or otherwise have the right +to pass it on as an open-source patch. The rules are pretty simple, +if you can certify the below: + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I have + the right to submit it under the open source license indicated in + the file; or + +(b) The contribution is based upon previous work that, to the best of + my knowledge, is covered under an appropriate open source License + and I have the right under that license to submit that work with + modifications, whether created in whole or in part by me, under + the same open source license (unless I am permitted to submit + under a different license), as indicated in the file; or + +(c) The contribution was provided directly to me by some other person + who certified (a), (b) or (c) and I have not modified it. + +(d) The contribution is made free of any other party's intellectual + property claims or rights. + +(e) I understand and agree that this project and the contribution are + public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. + + +then you just add a line saying + + Signed-off-by: Random J Developer + +using your real name (sorry, no pseudonyms or anonymous contributions.) diff --git a/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..e27376e --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1,15 @@ +# This is the official list of people who can contribute +# (and typically have contributed) code to the OVH::Bastion repository. +# +# Names should be added to this file only after verifying that +# the individual or the individual's organization has agreed to +# the appropriate CONTRIBUTING.md file. +# +# Names should be added to this file like so: +# Individual's name +# Individual's name +# +# Please keep the list sorted. +# +Adrien Barreau +Stéphane Lesimple diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..4ca5316 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,40 @@ +# The Bastion design choices + +This document aims to summarize a few design choices that have been made +on this project, that dictate how features are implemented. + +## Use the well trusted and existing UNIX building blocks, don't recode them + +The Bastion heavily relies on well known and trusted system blocks to work. +All the SSH part is completely handled by OpenSSH server and client programs. +The MFA mechanism also heavily relies on PAM. + +## The OS as a safety net for buggy or exploitable code + +A bastion functional user is always mapped to an actual operating system user. +Same goes for bastion groups: they're mapped to actual OS groups. +This is also true for group roles: gatekeeper, owner and aclkeeper roles are +mapped to system groups. + +Private keys of an account are only readable by the corresponding operating +system user, and same goes for the group private keys. This way, even if the +code is tricked to allow access when it shouldn't have (flawed logic or bug), +then the OS will still deny reading the key file. + +This concept has been explained in the ([https://www.ovh.com/blog/the-bastion-part-3-security-at-the-core/](Blog Post #3 - Security at the Core)) + +## Zero trust between portions of code running at different permission levels + +Most of The Bastion code is running under the unprivileged system user +corresponding to the actual user of the bastion. When some code needs to +run with privileges, for example to be able to create an account, a first +portion of the code checks for the validity of the request first, under the +same privileges than the user, this is called `a plugin`. +To actually create the system user, `sudo` is used to run just a specific +portion of the code. Such portions of code are named `helpers`, and always +run under perl tainted mode. + +Helpers communicate back their result using JSON, which is then read from +the plugin (the unprivileged portion of code), and parsed. + +This concept has been explained in the ([https://www.ovh.com/blog/the-bastion-part-3-security-at-the-core/](Blog Post #3 - Security at the Core)) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3a32a4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,192 @@ + Copyright 2020 OVHcloud + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + A copy of the license terms follows: + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/MAINTAINERS b/MAINTAINERS new file mode 100644 index 0000000..81c5c47 --- /dev/null +++ b/MAINTAINERS @@ -0,0 +1,12 @@ +# This is the official list of the project maintainers. +# This is mostly useful for contributors that want to push +# significant pull requests or for project management issues. +# +# +# Names should be added to this file like so: +# Individual's name +# Individual's name +# +# Please keep the list sorted. +# +Stéphane Lesimple diff --git a/README.md b/README.md new file mode 100644 index 0000000..e76b71b --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +The Bastion +=========== + +Bastions are a cluster of machines used as the unique entry point by operational teams (such as sysadmins, developers, database admins, ...) to securely connect to devices (servers, virtual machines, cloud instances, network equipment, ...), usually using `ssh`. + +Bastions provides mechanisms for authentication, authorization, traceability and auditability for the whole infrastructure. + +Learn more by reading the blog post series that announced the release: +- [https://www.ovh.com/blog/the-ovhcloud-bastion-part-1/](Part 1 - Genesis) +- [https://www.ovh.com/blog/the-ovhcloud-ssh-bastion-part-2-delegation-dizziness/](Part 2 - Delegation Dizziness) +- [https://www.ovh.com/blog/the-bastion-part-3-security-at-the-core/](Part 3 - Security at the Core) +- [https://www.ovh.com/blog/the-bastion-part-4-open-sourcing/](Part 4 - Open Sourcing) + +## Installing, upgrading, using The Bastion + +Please see the online documentation ([https://ovh.github.io/the-bastion](https://ovh.github.io/the-bastion)), or the corresponding text-based documentation which can be found in the `doc/` folder. + +## TL;DR + +### Testing it with Docker + +Let's build the docker image and run it + + docker build -f docker/Dockerfile.debian10 -t bastion:debian10 . + docker run -d -p 22 --name bastiontest bastion:debian10 + +Configure the first administrator account (get your public SSH key ready) + + docker exec -it bastiontest /opt/bastion/bin/admin/setup-first-admin-account.sh poweruser auto + +We're now up and running with the default configuration! Let's setup a handy bastion alias, and test the `info` command: + + PORT=$(docker port bastiontest | cut -d: -f2) + alias bastion="ssh poweruser@127.0.0.1 -tp $PORT -- " + bastion --osh info + +It should greet you as being a bastion admin, which means you have access to all commands. Let's enter interactive mode: + + bastion -i + +This is useful to call several `--osh` plugins in a row. Now we can ask for help to see all plugins: + + $> help + +If you have a remote machine you want to try to connect to through the bastion, fetch your egress key: + + $> selfListEgressKeys + +Copy this public key to the remote machine's `authorized_keys` under the `.ssh/` folder of the account you want to connect to, then: + + $> selfAddPersonalAccess --host --user --port-any + $> ssh @ + +Note that you can connect directly without using interactive mode, with: + + bastion @ + +That's it! Additional documentation is available under the `doc/` folder and online ([https://ovh.github.io/the-bastion](https://ovh.github.io/the-bastion)). +Be sure to check the help of the bastion (`bastion --help`) and the help of each osh plugin (`bastion --osh command --help`) +Also don't forget to customize your `bastion.conf` file, which can be found in `/etc/bastion/bastion.conf` (for Linux) + +## Compatibility + +Linux distros below are tested with each release, but as this is a security product, you are *warmly* advised to run it on the latest up-to-date stable version of your favorite OS: + +- Debian 10 (Buster), 9 (Stretch), 8 (Jessie) +- RHEL/CentOS 8, 7 +- Ubuntu LTS 20.04, 18.04, 16.04, 14.04* +- OpenSUSE Leap 15.1*, 15* + +*: Note that these versions have no MFA support. +Any other so-called "modern" Linux version are not tested with each release, but should work with no or minor adjustments. + +The code is also known to work correctly under: + +- FreeBSD 10+ / HardenedBSD [no MFA support] + +Other BSD variants partially work but are unsupported and discouraged as they have a severe limitation over the maximum number of supplementary groups (causing problems for group membership and restricted commands checks), no filesystem-level ACL support and missing MFA: + +- OpenBSD 5.4+ +- NetBSD 7+ + +## Reliability + +When hell is breaking loose on all your infrastructures and/or your network, bastions still need to be the last component standing because you need them to access the rest of your infrastructure... to be able to actually fix the problem. Hence reliability is key. + +* The KISS principle is used where possible for design and code: less complicated code means more auditability and less bugs +* Only a few well-known libraries are used, less third party code means a tinier attack surface +* The bastion is engineered to be self-sufficient: less dependencies such as databases, other daemons, or other machines, statistically means less downtime +* High availability can be setup so that multiple bastion instances form a cluster of several instances, with any instance usable at all times (active/active scheme) + +# Code quality + +* The code is ran under `perltidy` +* The code is also ran under `perlcritic` +* Functional tests are used before every release + +## Security at the core + +Even with the most conservative, precautionous and paranoid coding process, code has bugs, so it shouldn't be trusted blindly. Hence the bastion doesn't trust its own code. It leverages the operating system security primitives to get additional security, as seen below. + +- Uses the well-known and trusted UNIX Discretionary Access Control: + - Bastion users are mapped to actual system users + - Bastion groups are mapped to actual system groups + - All the code is constantly checking rights before allowing any action + - UNIX DAC is used as a safety belt to prevent an action from succeeding even if the code is tricked into allowing it + +- The bastion main script is declared as the bastion user's system shell: + - No user has real (`bash`-like) shell access on the system + - All code is ran under the unprivileged user's system account rights + - Even if a user could escape to a real shell, he wouldn't be able to connect to machines he doesn't have access to, because he doesn't have filesystem-level read access to the SSH keys + +- The code is modular + - The main code mainly checks rights, logs actions, and enable `ssh` access to other machines + - All side commands, called *plugins*, are in modules separated from the main code + - The modules can either be *open* or *restricted* + - Only accounts that have been specifically granted on a need-to-use basis can run a specific restricted plugin + - This is checked by the code, and also enforced by UNIX DAC (the plugin is only readable and executable by the system group specific to the plugin) + +- All the code needing extended system privileges is separated from the main code, in modules called *helpers* + - Helpers are run exclusively under `sudo` + - The `sudoers` configuration is attached to a system group specific to the command, which is granted to accounts on a need-to-use basis + - The helpers are only readable and executable by the system group specific to the command + - The helpers path and some of their immutable parameters are hardcoded in the `sudoers` configuration + - Perl tainted mode (`-T`) is used for all code running under `sudo`, preventing any user-input to interfere with the logic, by halting execution immediately + - Code running under `sudo` doesn't trust its caller and re-checks every input + - Communication between unprivileged and privileged-code are done using JSON + +## Auditability + +- Bastion administrators must use the bastion's logic to connect to itself to administer it (or better, use another bastion to do so), this ensures auditability in all cases +* Every access and action (wether allowed or denied) is logged with: + * `syslog`, which should also be sent to a remote syslog server to ensure even bastion administrators can't tamper their tracks, and/or + * local `sqlite3` databases for easy searching +* This code is used in production in several PCI-DSS, ISO 27001, SOC1 and SOC2 certified environments + +## License + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/bin/admin/build-and-install-ttyrec.sh b/bin/admin/build-and-install-ttyrec.sh new file mode 100755 index 0000000..94ee7d1 --- /dev/null +++ b/bin/admin/build-and-install-ttyrec.sh @@ -0,0 +1,57 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +TTYREC_ARCHIVE_URL='https://github.com/ovh/ovh-ttyrec/archive/master.zip' + +action_doing "Detecting OS..." +action_detail "Found $OS_FAMILY" +if [ "$OS_FAMILY" = Linux ]; then + action_detail "Found distro $LINUX_DISTRO version $DISTRO_VERSION (major $DISTRO_VERSION_MAJOR), distro like $DISTRO_LIKE" +fi +action_done + +if echo "$DISTRO_LIKE" | grep -q -w debian; then + list="make gcc unzip wget" + if [ "$LINUX_DISTRO" = debian ] && [ "$DISTRO_VERSION_MAJOR" -ge 9 ]; then + list="$list libzstd-dev" + elif [ "$LINUX_DISTRO" = ubuntu ] && [ "$DISTRO_VERSION_MAJOR" -ge 16 ]; then + list="$list libzstd-dev" + fi + apt-get update + # shellcheck disable=SC2086 + apt-get install -y $list + # shellcheck disable=SC2086 + cleanup() { + apt-get remove --purge -y $list + apt-get autoremove --purge -y + } +elif echo "$DISTRO_LIKE" | grep -q -w rhel; then + yum install -y gcc make unzip wget + cleanup() { yum remove -y gcc make unzip wget; } +elif echo "$DISTRO_LIKE" | grep -q -w suse; then + zypper install -y gcc make libzstd-devel-static unzip wget + cleanup() { zypper remove -y -u gcc make libzstd-devel-static unzip wget; } +else + echo "This script doesn't support this OS yet ($DISTRO_LIKE)" >&2 + exit 1 +fi + +cd /tmp +wget "$TTYREC_ARCHIVE_URL" +unzip master.zip +cd ovh-ttyrec-master +./configure +make +make install +cleanup + +if ttyrec -V; then + action_done "ttyrec correctly installed" +else + action_error "couldn't install ttyrec" +fi diff --git a/bin/admin/check-consistency.pl b/bin/admin/check-consistency.pl new file mode 100755 index 0000000..0bf7d5d --- /dev/null +++ b/bin/admin/check-consistency.pl @@ -0,0 +1,715 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Data::Dumper; +use Term::ANSIColor; +use Digest::MD5 (); + +use File::Basename; +my $BASEDIR = dirname(__FILE__) . '/../..'; + +my $MIN_KEYGROUP_GID = 2000; +my $MAX_KEYGROUP_GID = 99999; +my @KEY_GROUPS_IGNORE = qw{ keeper reader }; +my $HOME_SUBDIRS_IGNORE_RE = qr{^^}; + +my $bad; + +# generate a uniq prefix based on caller's lineno and caller's caller's lineno, useful to grep or grep -v +sub _prefix { return uc(unpack('H*', pack('S', (caller(1))[2])) . unpack('H*', pack('S', (caller(2))[2]))) . ": "; } + +sub info { print $_[0] . "\n"; return 1; } ## no critic (RequireArgUnpacking) +sub _wrn { $bad++; print colored(_prefix() . $_[0], "blue") . "\n"; return 1; } ## no critic (RequireArgUnpacking,ProhibitUnusedPrivateSubroutine) +sub _err { $bad++; print colored(_prefix() . $_[0], "red") . "\n"; return 1; } ## no critic (RequireArgUnpacking) +sub _crit { $bad++; print colored(_prefix() . $_[0], "bold red") . "\n"; return 1; } ## no critic (RequireArgUnpacking) + +# Linux and BSD don't always have the same account names for UID/GID 0 +my ($UID0) = (qx{getent passwd 0})[0] =~ /^([^:]+)/; ## no critic (ProhibitBacktickOperators) +my ($GID0) = (qx{getent group 0})[0] =~ /^([^:]+)/; ## no critic (ProhibitBacktickOperators) +my $islinux = (($^O =~ /linux/i) ? 1 : 0); +my $hasacls = (($^O =~ /linux|freebsd/i) ? 1 : 0); + +# get all the key* groups +my %keygroupsbyname = (); +my %aclkgroupsbyname = (); +my %gkgroupsbyname = (); +my %owgroupsbyname = (); +my %keygroupsbyid = (); +my %aclkgroupsbyid = (); +my %gkgroupsbyid = (); +my %owgroupsbyid = (); + +my $sudoers_dir = '/etc/sudoers.d'; +if (!-d $sudoers_dir && -d '/usr/pkg/etc/sudoers.d') { + $sudoers_dir = '/usr/pkg/etc/sudoers.d'; +} +elsif (!-d $sudoers_dir && !$islinux) { + $sudoers_dir = '/usr/local/etc/sudoers.d'; +} + +_err "/nonexistent exists" if -e "/nonexistent"; + +open(my $fh_group, '<', '/etc/group') or die $!; +while (<$fh_group>) { + /^key([^:]+):[^:]+:(\d+)/ or next; + my $name = $1; + my $id = $2; + + if (exists $keygroupsbyname{$name} or exists $gkgroupsbyname{$name} or exists $owgroupsbyname{$name} or exists $aclkgroupsbyname{$name}) { + _err "group $name already seen!"; + } + if ($name =~ /-gatekeeper$/) { + $gkgroupsbyname{$name} = {name => $name, id => $id}; + } + elsif ($name =~ /-aclkeeper$/) { + $aclkgroupsbyname{$name} = {name => $name, id => $id}; + } + elsif ($name =~ /-owner$/) { + $owgroupsbyname{$name} = {name => $name, id => $id}; + } + else { + $keygroupsbyname{$name} = {name => $name, id => $id}; + } + + if (exists $keygroupsbyid{$id} or exists $gkgroupsbyid{$id} or exists $owgroupsbyid{$id} or exists $aclkgroupsbyname{$id}) { + _crit "group $name 's ID already seen!"; + } + if ($name =~ /-gatekeeper$/) { + $gkgroupsbyid{$id} = {name => $name, id => $id}; + } + elsif ($name =~ /-aclkeeper$/) { + $aclkgroupsbyid{$id} = {name => $name, id => $id}; + } + elsif ($name =~ /-owner$/) { + $owgroupsbyid{$id} = {name => $name, id => $id}; + } + else { + $keygroupsbyid{$id} = {name => $name, id => $id}; + } + + if (grep { $name eq $_ } @KEY_GROUPS_IGNORE) { + delete $keygroupsbyname{$name}; + delete $keygroupsbyid{$id}; + next; + } + if ($id > $MAX_KEYGROUP_GID) { _err "group $name id $id is too high"; } + if ($id < $MIN_KEYGROUP_GID) { _err "group $name id $id is too low"; } +} +close($fh_group); +info "found " . (scalar keys %keygroupsbyname) . " key groups"; + +# checking if allowkeeper is a member of all keygroups +my @allowkeeper_groups = split(/ /, qx/groups allowkeeper/); ## no critic (ProhibitBacktickOperators) +chomp @allowkeeper_groups; + +# some outputs of `groups` include "$username :" as a prefix, strip that +if ($allowkeeper_groups[0] eq 'allowkeeper' && $allowkeeper_groups[1] eq ':') { + @allowkeeper_groups = splice @allowkeeper_groups, 2; +} +foreach my $group (keys %keygroupsbyname) { + _err "allowkeeper user is not a member of group key$group" if (not grep { $_ eq "key$group" } @allowkeeper_groups); +} + +# now check if each key group has a gk +# and vice versa +foreach my $group (keys %keygroupsbyname) { + next if exists $gkgroupsbyname{$group . "-gatekeeper"}; + _err "key group $group is missing a gatekeeper group"; +} +foreach my $groupori (keys %gkgroupsbyname) { + my $group = $groupori; + $group =~ s/-gatekeeper$//; + next if exists $keygroupsbyname{$group}; + _err "gatekeeper group $group is missing a key group"; +} + +foreach my $group (keys %keygroupsbyname) { + next if exists $owgroupsbyname{$group . "-owner"}; + _err "key group $group is missing an owner group"; +} +foreach my $groupori (keys %owgroupsbyname) { + my $group = $groupori; + $group =~ s/-owner$//; + next if exists $keygroupsbyname{$group}; + _err "owner group $group is missing a key group"; +} + +# now check if each key group has a /home/key* $HOME +# and vice versa +my @keyhomesfound; +opendir(my $dh, "/home/") or die $!; +while (my $file = readdir($dh)) { + next unless -d "/home/$file"; + next if $file eq '.'; + next if $file eq '..'; + if ($file !~ /[a-zA-Z0-9_-]+$/) { + _err "bad chars in /home/$file"; + next; + } + push @keyhomesfound, $file if $file =~ /^key/; +} +foreach my $file (@keyhomesfound) { + my $file2 = $file; + $file2 =~ s/^key//; + next if exists $keygroupsbyname{$file2}; + next if (grep { $file2 eq $_ } @KEY_GROUPS_IGNORE); + _err "directory /home/key$file2 exists but no key group $file2"; +} +foreach my $group (keys %keygroupsbyname) { + next if -d "/home/key$group"; + _err "key group $group is missing /home/key$group"; +} + +my %ALL_FILES; +foreach (qx{find /home/key* /home/keykeeper /home/allowkeeper -print}) { ## no critic (ProhibitBacktickOperators) + chomp; + /$HOME_SUBDIRS_IGNORE_RE/ and next; + $ALL_FILES{$_} = 1; +} +while (my $homedir = glob '/home/*') { + -d $homedir or next; + -d "$homedir/ttyrec" or next; + next if $homedir eq '/home/proxyhttp'; + next if $homedir eq '/home/healthcheck'; + + #$ALL_FILES{$_} = 1; + #$ALL_FILES{$_.'/ttyrec'} = 1; + #$ALL_FILES{$_.'/.ssh'} = 1; + #$ALL_FILES{$_.'/osh.log'} = 1; + my ($user) = $homedir =~ m{/([^/]+)$}; + my $usertty = "$user-tty"; + if (not getgrnam($usertty)) { + $usertty = substr($user, 0, 5) . '-tty'; + } + check_file_rights("$homedir", + ["# file: $homedir", "# owner: $user", "# group: $user", "user::rwx", "group::r-x", "group:$usertty:--x", "group:osh-auditor:--x", "mask::r-x", "other::---",], + "drwxr-x--x", $user, $user); + check_file_rights( + "$homedir/ttyrec", + [ + "# file: $homedir/ttyrec", "# owner: $user", "# group: $user", "user::rwx", "group::---", "group:$usertty:r-x", + "mask::r-x", "other::---", "default:user::rwx", "default:group::---", "default:group:$usertty:r-x", "default:mask::r-x", + "default:other::---", + ], + "drwxrwxr-x", + $user, $user + ); + check_file_rights("$homedir/.ssh", + ["# file: $homedir/.ssh", "# owner: $user", "# group: $user", "user::rwx", "group::r-x", "group:osh-auditor:--x", "mask::r-x", "other::---",], + "drwxr-x---", $user, $user); + if (-e "$homedir/osh.log") # doesn't exist? nevermind + { + check_file_rights("$homedir/osh.log", ["# file: $homedir/osh.log", "# owner: $user", "# group: $user", "user::rw-", "group::r--", "other::---",], + "-rw-r-----", $user, $user); + } + + # now check all keys in ~/.ssh + opendir(my $dh, "$homedir/.ssh") or die "$homedir/.ssh: $!"; + while (my $keyfile = readdir($dh)) { + next unless $keyfile =~ /^id_|private/; + my $ret = check_file_rights( + "$homedir/.ssh/$keyfile", + [ + "# file: $homedir/.ssh/$keyfile", + "# owner: $user", + "# group: $user", + "user::r--", + $keyfile =~ /\.pub$/ ? "group::r--" : "group::---", + $keyfile =~ /\.pub$/ ? "other::r--" : "other::---", + ], + $keyfile =~ /\.pub$/ ? "-r--r--r--" : "-r--------", + $user, $user + ); + if ($keyfile !~ /\.pub$/) { + if (not $ret) { + + # wow ! private key readable ? + _crit "due to above error, private key $homedir/.ssh/$keyfile might be readable !!"; + } + } + else { + # check for spurious "from" in .pub + open(my $pubfh, '<', "$homedir/.ssh/$keyfile") or die "$homedir/.ssh/$keyfile: $!"; + while (<$pubfh>) { + /from=/ and _err "spurious from='...' in $homedir/.ssh/$keyfile"; + } + close($pubfh); + } + } + close($dh); +} + +sub check_file_rights { + my $file = shift; + my $expectedOutput = shift; + my $expectedmodes = shift; + my $expectedowner = shift; + my $expectedgroup = shift; + + #info "checking rights of $file"; + delete $ALL_FILES{$file}; + my $ok = 1; + + if (not -e $file) { + _err "file $file doesn't exist!"; + $ok = 0; + return $ok; + } + + if (!$hasacls) { + my ($modes, $owner, $group) = (qx{ls -ld $file})[0] =~ m{(\S+)\s+\d+\s+(\S+)\s+(\S+)}; ## no critic (ProhibitBacktickOperators) + if ($modes ne $expectedmodes) { $ok = 0; _err "on $file got $modes wanted $expectedmodes"; } + if ($owner ne $expectedowner) { $ok = 0; _err "on $file got $owner wanted $expectedowner"; } + if ($group ne $expectedgroup) { $ok = 0; _err "on $file got $group wanted $expectedgroup"; } + return $ok; + } + + my $param = ($islinux ? '-p' : ''); + my @out = qx{getfacl $param $file 2>/dev/null}; ## no critic (ProhibitBacktickOperators) + chomp @out; + my $lineno = -1; + $expectedOutput = [sort @$expectedOutput]; + @out = grep { /./ } sort @out; + foreach my $outLine (@out) { + next if not $outLine; + $lineno++; + $outLine eq $expectedOutput->[$lineno] and next; + $ok = 0; + _err "rights of $file, line$lineno, expected '" . $expectedOutput->[$lineno] . "' but got '" . $outLine . "'"; + } + if (@out != @$expectedOutput) { + _err "rights of $file, number of lines unexpected (got " . @out . " instead of " . @$expectedOutput . ")"; + $ok = 0; + } + return $ok; +} + +# now check what is in /home/key* and the rights +foreach my $file (@keyhomesfound) { + delete $ALL_FILES{"/home/$file/.bash_logout"}; + delete $ALL_FILES{"/home/$file/.bashrc"}; + delete $ALL_FILES{"/home/$file/.profile"}; + delete $ALL_FILES{"/home/$file/.ssh"}; + delete $ALL_FILES{"/home/$file/.ssh/known_hosts"}; + + # check rights of /home/keytruc + if (-e "/home/$file") { + if ($file ne 'keykeeper' and $file ne 'keyreader') { + check_file_rights( + "/home/$file", + [ + "# file: /home/$file", "# owner: $file", "# group: $file", "user::rwx", + "group::r-x", "group:osh-whoHasAccessTo:--x", "group:osh-auditor:--x", "group:$file-aclkeeper:--x", + "group:$file-gatekeeper:--x", "group:$file-owner:--x", "mask::r-x", "other::---", + ], + "drwxr-x--x", + $file, $file + ); + } + else { + check_file_rights( + "/home/$file", + [ + "# file: /home/$file", + "# owner: $file", + "# group: $file", + "user::rwx", + "group::r-x", + $file eq 'keykeeper' ? "other::r-x" : "other::---", # special dir /home/keykeeper is 755 + ], + $file eq 'keykeeper' ? "drwxr-xr-x" : "drwxr-x---", + $file, + $file + ); + } + } + else { + _err "/home/$file doesn't exist"; + } + next if (grep { $file eq "key$_" } @KEY_GROUPS_IGNORE); + + # check rights of /home/keytruc/allowed.ip + if (-e "/home/$file/allowed.ip") { + + #not -s "/home/$file/allowed.ip" and _wrn "group $file has no servers"; + check_file_rights("/home/$file/allowed.ip", ["# file: /home/$file/allowed.ip", "# owner: $file", "# group: $file-aclkeeper", "user::rw-", "group::rw-", "other::r--",], + "-rw-rw-r--", $file, "$file-aclkeeper"); + } + else { + _err "/home/$file/allowed.ip doesn't exist"; + } + + # check rights of /home/keykeeper/keytruc/ + if (-e "/home/keykeeper/$file") { + check_file_rights("/home/keykeeper/$file", ["# file: /home/keykeeper/$file", "# owner: keykeeper", "# group: $file", "user::rwx", "group::r-x", "other::r-x",], + "drwxr-xr-x", "keykeeper", $file); + } + else { + _err "/home/keykeeper/$file doesn't exist"; + } + + # check rights of /home/keykeeper/keytruc/id_* + opendir(my $dh, "/home/keykeeper/$file") or die "/home/keykeeper/$file: $!"; + while (my $keyfile = readdir($dh)) { + next unless $keyfile =~ /^id_/; # spurious files will be reported below + my $ret = check_file_rights( + "/home/keykeeper/$file/$keyfile", + ["# file: /home/keykeeper/$file/$keyfile", "# owner: keykeeper", "# group: $file", "user::r--", "group::r--", $keyfile =~ /\.pub$/ ? "other::r--" : "other::---",], + $keyfile =~ /\.pub$/ ? "-r--r--r--" : "-r--r-----", + "keykeeper", $file + ); + if ($keyfile !~ /\.pub$/) { + if (not $ret) { + + # wow ! private key readable ? + _crit "due to above error, private key /home/keykeeper/$file/$keyfile might be readable !!"; + } + } + else { + # check for spurious "from" in .pub + open(my $pubfh, '<', "/home/keykeeper/$file/$keyfile") or die "/home/keykeeper/$file/$keyfile: $!"; + while (<$pubfh>) { + /from=/ and _err "spurious from='...' in /home/keykeeper/$file/$keyfile"; + } + close($pubfh); + } + } + close($dh); +} + +# check some special dirs +check_file_rights("/home/allowkeeper", ["# file: /home/allowkeeper", "# owner: allowkeeper", "# group: allowkeeper", "user::rwx", "group::r-x", "other::r-x",], + "drwxr-xr-x", "allowkeeper", "allowkeeper"); +check_file_rights("/home/keykeeper", ["# file: /home/keykeeper", "# owner: keykeeper", "# group: keykeeper", "user::rwx", "group::r-x", "other::r-x",], + "drwxr-xr-x", "keykeeper", "keykeeper"); +check_file_rights("/home/logkeeper", ["# file: /home/logkeeper", "# owner: $UID0", "# group: bastion-users", "user::rwx", "group::-wx", "other::---",], + "drwx-wx---", $UID0, "bastion-users"); +check_file_rights("/home/passkeeper", ["# file: /home/passkeeper", "# owner: $UID0", "# group: $GID0", "user::rwx", "group::r-x", "other::r-x",], "drwxr-xr-x", $UID0, $GID0); +check_file_rights("/home/oldkeeper", ["# file: /home/oldkeeper", "# owner: $UID0", "# group: $GID0", "user::rwx", "group::---", "other::---",], "drwx------", $UID0, $GID0) + if -e "/home/oldkeeper"; + +# now get all bastion users +my %users; +my %usersbyid; +setpwent(); +while (my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $home, $shell, $expire) = getpwent()) { + if ($shell =~ /osh.pl$|diverter.sh$/) { + if (exists $users{$name}) { + _err "duplicate user $name"; + } + if (exists $usersbyid{$uid}) { + _err "duplicate uid for user $name"; + } + if ($home ne "/home/$name") { + _err "bad home for $name: $home"; + } + if (!-d $home) { + _err "home of $name doesn't exist ($home)"; + } + $users{$name} = {name => $name, uid => $uid, gid => $gid, shell => $shell}; + $usersbyid{$uid} = {name => $name, uid => $uid, gid => $gid, shell => $shell}; + } + + # TODO check qui a un shell access +} +info "found " . (scalar keys %users) . " bastion users"; + +my %groups; +my %usergroups; +setgrent(); +while (my ($name, $passwd, $gid, $members) = getgrent()) { + $groups{$name} = {name => $name, gid => $gid, members => [split(/ /, $members)]}; + foreach my $member (split(/ /, $members)) { + push @{$usergroups{$member}}, $name; + } +} + +info "found " . (scalar keys %groups) . " groups"; + +# check that user keyreader is a member of all bastion users primary groups +my %keyreaderuserseen; +foreach my $group (@{$usergroups{'keyreader'}}) { + $keyreaderuserseen{$group} = 1; +} +foreach my $user (keys %users) { + next if (exists $keyreaderuserseen{$user}); + _err "user $user primary group doesn't have keyreader as member"; + if ($ENV{'FIX_KEYREADER'}) { + system("usermod -a -G $user keyreader"); + _err "... fixed!"; + } +} + +# check if user has /home/allowkeeper/testuser4/allowed.private +foreach my $account (keys %users) { + check_file_rights("/home/allowkeeper/$account", + ["# file: /home/allowkeeper/$account", "# owner: allowkeeper", "# group: allowkeeper", "user::rwx", "group::r-x", "other::r-x",], + "drwxr-xr-x", "allowkeeper", "allowkeeper"); + check_file_rights( + "/home/allowkeeper/$account/allowed.ip", + ["# file: /home/allowkeeper/$account/allowed.ip", "# owner: allowkeeper", "# group: allowkeeper", "user::rw-", "group::r--", "other::r--",], + "-rw-r--r--", "allowkeeper", "allowkeeper" + ); + check_file_rights( + "/home/allowkeeper/$account/allowed.private", + ["# file: /home/allowkeeper/$account/allowed.private", "# owner: allowkeeper", "# group: allowkeeper", "user::rw-", "group::r--", "other::r--",], + "-rw-r--r--", "allowkeeper", "allowkeeper" + ); + if (!-e "/home/allowkeeper/$account/allowed.private" && $ENV{'FIX_MISSING_PRIVATE_FILES'}) { + if (open(my $fh_priv, '>', "/home/allowkeeper/$account/allowed.private")) { + close($fh_priv); + } + chmod 0644, "/home/allowkeeper/$account/allowed.private"; + my (undef, undef, $allowkeeperuid, $allowkeepergid) = getpwnam("allowkeeper"); + chown $allowkeeperuid, $allowkeepergid, "/home/allowkeeper/$account/allowed.private"; + _err "... fixed!"; + } + + # check all allowed.ip.GROUP symlinks + my $dh; + if (-d "/home/allowkeeper/$account") { + opendir($dh, "/home/allowkeeper/$account"); + while (my $file = readdir($dh)) { + if ($file =~ /^config\.[a-zA-Z0-9_-]+$/) { + delete $ALL_FILES{"/home/allowkeeper/$account/$file"}; + next; + } + elsif ($file !~ /^allowed\.(ip|partial)\.([a-zA-Z0-9_-]+)$/) { + next; + } + + if (not grep { $2 eq $_ } keys %keygroupsbyname) { + _err "file /home/allowkeeper/$account/$file has no corresponding known group"; + } + if ($1 eq 'ip') { + if (not -l "/home/allowkeeper/$account/$file") { + _err "file /home/allowkeeper/$account/$file should have been a symlink"; + } + } + elsif ($1 eq 'partial') { + if (not -f "/home/allowkeeper/$account/$file") { + _err "file /home/allowkeeper/$account/$file should have been a plain file"; + } + } + else { + _err "hmm, bug in the script ? got a '$1'"; + } + delete $ALL_FILES{"/home/allowkeeper/$account/$file"}; + } + close($dh); + } +} + +delete $ALL_FILES{'/home/allowkeeper'}; +delete $ALL_FILES{'/home/allowkeeper/.bash_logout'}; +delete $ALL_FILES{'/home/allowkeeper/.bashrc'}; +delete $ALL_FILES{'/home/allowkeeper/.profile'}; +delete $ALL_FILES{'/home/allowkeeper/.ssh'}; +delete $ALL_FILES{'/home/allowkeeper/activeLogin.json'}; +delete $ALL_FILES{'/home/allowkeeper/expirationGrant.json'}; + +if (keys %ALL_FILES) { + _err "got some potentially unknown files:"; + print Dumper(sort keys %ALL_FILES); +} + +# for new code, check sudo stuff +sub _tocheck { + my $file = shift; + my $filesuffix = shift; + my $tocheckref = shift; + my %tocheck = %$tocheckref; + + if (exists $tocheck{'NEEDGROUP'}) { + my $group = $tocheck{'NEEDGROUP'}[0]; + my $gid = getgrnam($group); + if (not defined $gid) { + _err "missing group $group"; + } + elsif ($gid > 1000) { + _err "group $group has a too high gid ($gid)"; + } + } + my @stat = stat($file); + if (exists $tocheck{'FILEMODE'}) { + my $mode = sprintf '%04o', $stat[2] & oct(7777); + if ($mode ne $tocheck{'FILEMODE'}[0]) { + _err "bad file mode on $file, got $mode but expected " . $tocheck{'FILEMODE'}[0]; + } + } + if (exists $tocheck{'FILEOWN'}) { + my $uid = $stat[4]; + my $gid = $stat[5]; + my $wantuser = (split / /, $tocheck{'FILEOWN'}[0])[0]; + my $wantgroup = (split / /, $tocheck{'FILEOWN'}[0])[1]; + $wantuser = $UID0 if $wantuser eq 'root'; + $wantgroup = $GID0 if $wantgroup eq 'root'; + if ($uid ne getpwnam($wantuser)) { + _err "bad owner on file $file (got $uid but wanted $wantuser)"; + } + if ($gid ne getgrnam($wantgroup)) { + _err "bad group on file $file (got $gid but wanted $wantgroup)"; + } + } + if (exists $tocheck{'SUDOERS'}) { + my $sudoersfile = "$sudoers_dir/osh-plugin-" . $filesuffix; + if (not -f $sudoersfile) { + _err "sudoers file $sudoersfile doesn't exists"; + } + else { + my $mode = sprintf '%04o', (stat($sudoersfile))[2] & oct(7777); + if ($mode ne "0440") { + _err "sudoers file $sudoersfile has a bad mode $mode"; + } + if (!open(my $fh_sudoers, '<', $sudoersfile)) { + _err "can't open sudoers file $sudoersfile to check"; + } + else { + my @contents = <$fh_sudoers>; + close($fh_sudoers); + chomp @contents; + foreach my $wantedline (@{$tocheck{'SUDOERS'}}) { + if (not grep { $_ eq $wantedline } @contents) { + _err "missing line in plugin $sudoersfile: $wantedline"; + } + } + } + } + } + if (exists $tocheck{'KEYSUDOERS'}) { + my @contents; + foreach my $sudoersfile (sort <$BASEDIR/etc/sudoers.group.template.d/*>) { + if (!open(my $fh_sudoers, '<', $sudoersfile)) { + _err "can't open sudoers file template $sudoersfile to check"; + } + else { + my @lines = <$fh_sudoers>; + close($fh_sudoers); + chomp @lines; + push @contents, @lines; + } + } + if (@contents) { + foreach my $wantedline (@{$tocheck{'KEYSUDOERS'}}) { + $wantedline =~ s'@KEYGROUP@'%GROUP%'g; + if (not grep { $_ eq $wantedline } @contents) { + _err "missing line in plugin sudoers.group.template: $wantedline"; + } + } + } + } + foreach my $key (qw{ FILEMODE FILEOWN SUDOERS NEEDGROUP KEYSUDOERS }) { + delete $tocheck{$key}; + } + if (keys %tocheck) { + _err "hum sparse tocheck key: " . join(" ", sort keys %tocheck); + } + + return 1; +} + +while (my $file = glob "$BASEDIR/bin/helper/*") { + my ($filesuffix) = $file =~ m{/osh-([a-zA-Z0-9_-]+$)}; + if (!$filesuffix) { + _err "helper file has a strange name ($file)"; + next; + } + my $fh_helper; + if (!open($fh_helper, '<', $file)) { + _err "can't open helper file $file to check"; + next; + } + my %tochecklocal; + while (<$fh_helper>) { + /^#/ or last; + if (/^\s*#\s*$/) { + _tocheck($file, $filesuffix, \%tochecklocal); + %tochecklocal = (); + next; + } + /^# ([A-Z0-9]+) (.+)$/ or next; + my ($keyword, $line) = ($1, $2); + push @{$tochecklocal{$keyword}}, $line; + } + close($fh_helper); + + if (%tochecklocal) { + _tocheck($file, $filesuffix, \%tochecklocal); + } +} + +# check /etc/sudoers.d vs $BASEDIR/etc/sudoers.d +# FIXME won't see if we have too many / old files in /etc/sudoers.d +while (my $distfile = glob "$BASEDIR/etc/sudoers.d/*") { + my $prodfile = $distfile; + $prodfile =~ s=^\Q$BASEDIR\E/etc/sudoers.d=$sudoers_dir=; + if (-e $prodfile) { + my @md5sums; + foreach my $file ($prodfile, $distfile) { + if (open(my $fh, '<', $file)) { + binmode($fh); + push @md5sums, Digest::MD5->new->addfile($fh)->hexdigest; + close($fh); + } + else { + push @md5sums, "ERR($file)"; + } + } + if ($md5sums[0] ne $md5sums[1]) { + _err "sudoers file $distfile and $prodfile differ"; + } + } + else { + _err "sudoers file $prodfile not found"; + } +} + +if (1) { + my @template; + foreach my $sudoersfile (sort <$BASEDIR/etc/sudoers.group.template.d/*>) { + if (!open(my $fh_sudoers, '<', $sudoersfile)) { + _err "can't open sudoers file template $sudoersfile to check"; + } + else { + my @lines = <$fh_sudoers>; + close($fh_sudoers); + chomp @lines; + push @template, @lines; + } + } + + my %seensudogroupfile; + while (my $sudoersfile = glob "$sudoers_dir/osh-group-*") { + + # TODO check 0440 + # TODO check there's a matching group (and the other way around) + my $group = $sudoersfile; + $group =~ s/^.*osh-group-key//; + $seensudogroupfile{$group} = 1; + my $fh_sudoers; + if (!open($fh_sudoers, '<', $sudoersfile)) { + _err "can't open $sudoersfile file to check: $!"; + next; + } + my @contents = <$fh_sudoers>; + close($fh_sudoers); + chomp @contents; + + my @expected = @template; + do { s/%GROUP%/key$group/g; s=%BASEPATH%=/opt/bastion=g; } + for @expected; + + foreach (@expected) { + my $wantedline = $_; # copy + if (not grep { $_ eq $wantedline } @contents) { + _err "missing line in $sudoersfile: $wantedline"; + } + } + } + foreach my $group (keys %keygroupsbyname) { + next if exists $seensudogroupfile{$group}; + _err "missing $sudoers_dir/osh-group-key$group file"; + } +} + +exit($bad > 255 ? 255 : $bad); diff --git a/bin/admin/check-ssh-hardening.pl b/bin/admin/check-ssh-hardening.pl new file mode 100755 index 0000000..2ed4737 --- /dev/null +++ b/bin/admin/check-ssh-hardening.pl @@ -0,0 +1,545 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; +use IPC::Open2; +use MIME::Base64; +use Getopt::Long; +use File::Temp qw{ tempfile }; + +my $hideok = 0; + +sub ko ## no critic (RequireArgUnpacking) +{ + print colored("[ERR!] " . $_[0] . "\n", "red"); + return 1; +} + +sub ok ## no critic (RequireArgUnpacking) +{ + $hideok and return 1; + print colored("[ ok ] " . $_[0] . "\n", "green"); + return 1; +} + +sub wrn ## no critic (RequireArgUnpacking) +{ + print colored("[warn] " . $_[0] . "\n", "yellow"); + return 1; +} + +sub inf ## no critic (RequireArgUnpacking) +{ + print colored("[info] " . $_[0] . "\n", "blue"); + return 1; +} + +my $generate_moduli; +GetOptions( + 'hide-ok' => \$hideok, + 'generate-moduli=i', \$generate_moduli +); + +my (%h, %d); + +# %h contains the sshd configuration for this host +# %d contains the default sshd configuration of this sshd version + +my $fh_cmd; +open($fh_cmd, '-|', '/usr/sbin/sshd -T 2>/dev/null') or die($!); +while (<$fh_cmd>) { + /^(\S+)\s+(.+)$/ and push @{$h{$1}}, $2; +} +if (not keys %h) { + + # newer openssh versions need some context to give their config + open($fh_cmd, '-|', '/usr/sbin/sshd -T -C user=root -C host=localhost -C addr=localhost 2>/dev/null') or die($!); + while (<$fh_cmd>) { + /^(\S+)\s+(.+)$/ and push @{$h{$1}}, $2; + } +} +close($fh_cmd); +open($fh_cmd, '-|', "/usr/sbin/sshd -T -f /dev/null 2>/dev/null") or die($!); +while (<$fh_cmd>) { + /^(\S+)\s+(.+)$/ and push @{$d{$1}}, $2; +} +close($fh_cmd); + +# hacky way to find out ciphers/kex/macs on old sshd versions +if (not $d{ciphers} or not $d{kexalgorithms} or not $d{macs}) { + + # hacky way + if (!open($fh_cmd, '-|', "strings /usr/sbin/sshd")) { + ko "Error trying to get the ciphers/kexs/macs list ($!)"; + } + else { + my ($ciphers, $kexalgorithms, $macs); + while (<$fh_cmd>) { + /arcfour128,/ and $ciphers = $_; + /mac-sha1,/ and $macs = $_; + /diffie-hellman.*,.*diffie-hellman/ and $kexalgorithms = $_; + } + close($fh_cmd); + chomp($ciphers, $macs, $kexalgorithms); + $d{ciphers} or $d{ciphers}[0] = $ciphers; + $h{ciphers} or $h{ciphers}[0] = $ciphers; + $d{macs} or $d{macs}[0] = $macs; + $h{macs} or $h{macs}[0] = $macs; + $d{kexalgorithms} or $d{kexalgorithms}[0] = $kexalgorithms; + $h{kexalgorithms} or $h{kexalgorithms}[0] = $kexalgorithms; + } +} + +my @myciphers = split /,/, $h{ciphers}[0]; +my %ciphers = ( + "3des-cbc" => 1, + "blowfish-cbc" => 1, + "cast128-cbc" => 1, + "arcfour" => 1, + "arcfour128" => 1, + "arcfour256" => 1, + "aes128-cbc" => 2, + "aes192-cbc" => 2, + "aes256-cbc" => 2, + "rijndael-cbc\@lysator.liu.se" => 2, + "aes128-ctr" => 3, + "aes192-ctr" => 3, + "aes256-ctr" => 3, + "aes128-gcm\@openssh.com" => 3, + "aes256-gcm\@openssh.com" => 3, + "chacha20-poly1305\@openssh.com" => 3, +); +my %list; +foreach my $cipher (split /,/, $d{ciphers}[0]) { + if ($ciphers{$cipher} == 1) { + push @{$list{((grep { $cipher eq $_ } @myciphers) ? 'weakon' : 'weakoff')}}, $cipher; + } + elsif ($ciphers{$cipher} == 2) { + push @{$list{((grep { $cipher eq $_ } @myciphers) ? 'mediumon' : 'mediumoff')}}, $cipher; + } + elsif ($ciphers{$cipher} == 3) { + push @{$list{((grep { $cipher eq $_ } @myciphers) ? 'highon' : 'highoff')}}, $cipher; + } + else { push @{$list{'unknown'}}, $cipher } +} +$list{'weakon'} and wrn "ciphers: found enabled weak ciphers " . join(',', @{$list{'weakon'}}); +$list{'weakoff'} and ok "ciphers: found disabled weak ciphers " . join(',', @{$list{'weakoff'}}); +$list{'mediumon'} and ok "ciphers: found enabled medium-grade ciphers " . join(',', @{$list{'mediumon'}}); +$list{'mediumoff'} and ok "ciphers: found disabled medium-grade ciphers " . join(',', @{$list{'mediumoff'}}); +$list{'highon'} and ok "ciphers: found enabled high-grade ciphers " . join(',', @{$list{'highon'}}); +$list{'highoff'} and wrn "ciphers: found disabled high-grade ciphers " . join(',', @{$list{'highoff'}}); + +my @mymacs = split /,/, $h{macs}[0]; +my %macs = ( + "hmac-sha1" => 1, + "hmac-sha1-96" => 1, + "hmac-sha2-256" => 2, + "hmac-sha2-512" => 2, + "hmac-md5" => 1, + "hmac-md5-96" => 1, + "hmac-ripemd160" => 1, + "hmac-ripemd160\@openssh.com" => 1, + "umac-64\@openssh.com" => 2, + "umac-128\@openssh.com" => 2, + "hmac-sha1-etm\@openssh.com" => 1, + "hmac-sha1-96-etm\@openssh.com" => 1, + "hmac-sha2-256-etm\@openssh.com" => 3, + "hmac-sha2-512-etm\@openssh.com" => 3, + "hmac-md5-etm\@openssh.com" => 1, + "hmac-md5-96-etm\@openssh.com" => 1, + "hmac-ripemd160-etm\@openssh.com" => 2, + "umac-64-etm\@openssh.com" => 2, + "umac-128-etm\@openssh.com" => 2, + "hmac-sha2-256-96" => 2, + "hmac-sha2-512-96" => 2 +); +%list = (); + +foreach my $mac (split /,/, $d{macs}[0]) { + if (not exists $macs{$mac}) { + wrn "Unknown mac $mac"; + next; + } + if ($macs{$mac} == 1) { + push @{$list{((grep { $mac eq $_ } @mymacs) ? 'weakon' : 'weakoff')}}, $mac; + } + elsif ($macs{$mac} == 2) { + push @{$list{((grep { $mac eq $_ } @mymacs) ? 'mediumon' : 'mediumoff')}}, $mac; + } + elsif ($macs{$mac} == 3) { + push @{$list{((grep { $mac eq $_ } @mymacs) ? 'highon' : 'highoff')}}, $mac; + } + else { push @{$list{'unknown'}}, $mac } +} +$list{'weakon'} and wrn "macs: found enabled weak MACs " . join(',', @{$list{'weakon'}}); +$list{'weakoff'} and ok "macs: found disabled weak MACs " . join(',', @{$list{'weakoff'}}); +$list{'mediumon'} and ok "macs: found enabled medium-grade MACs " . join(',', @{$list{'mediumon'}}); +$list{'mediumoff'} and ok "macs: found disabled medium-grade MACs " . join(',', @{$list{'mediumoff'}}); +$list{'highon'} and ok "macs: found enabled high-grade MACs " . join(',', @{$list{'highon'}}); +$list{'highoff'} and wrn "macs: found disabled high-grade MACs " . join(',', @{$list{'highoff'}}); + +my @mykexs = split /,/, $h{kexalgorithms}[0]; +my %kexs = ( + "diffie-hellman-group1-sha1" => 1, + "diffie-hellman-group14-sha1" => 1, + "diffie-hellman-group-exchange-sha1" => 1, + "diffie-hellman-group-exchange-sha256" => 3, + "ecdh-sha2-nistp256" => 2, + "ecdh-sha2-nistp384" => 2, + "ecdh-sha2-nistp521" => 2, + "curve25519-sha256\@libssh.org" => 3, + "curve25519-sha256" => 3, + "diffie-hellman-group16-sha512" => 3, + "diffie-hellman-group18-sha512" => 3, + "diffie-hellman-group14-sha256" => 3, +); +%list = (); + +foreach my $kex (split /,/, $d{kexalgorithms}[0]) { + if (not exists $kexs{$kex}) { + wrn "Unknown kex $kex"; + next; + } + if ($kexs{$kex} == 1) { + push @{$list{((grep { $kex eq $_ } @mykexs) ? 'weakon' : 'weakoff')}}, $kex; + } + elsif ($kexs{$kex} == 2) { + push @{$list{((grep { $kex eq $_ } @mykexs) ? 'mediumon' : 'mediumoff')}}, $kex; + } + elsif ($kexs{$kex} == 3) { + push @{$list{((grep { $kex eq $_ } @mykexs) ? 'highon' : 'highoff')}}, $kex; + } + else { push @{$list{'unknown'}}, $kex } +} +$list{'weakon'} and wrn "kexs: found enabled weak KEXs " . join(',', @{$list{'weakon'}}); +$list{'weakoff'} and ok "kexs: found disabled weak KEXs " . join(',', @{$list{'weakoff'}}); +$list{'mediumon'} and ok "kexs: found enabled medium-grade KEXs " . join(',', @{$list{'mediumon'}}); +$list{'mediumoff'} and ok "kexs: found disabled medium-grade KEXs " . join(',', @{$list{'mediumoff'}}); +$list{'highon'} and ok "kexs: found enabled high-grade KEXs " . join(',', @{$list{'highon'}}); +$list{'highoff'} and wrn "kexs: found disabled high-grade KEXs " . join(',', @{$list{'highoff'}}); + +my $hasecdsa = 0; +my $hased25519 = 0; +my $hasrsa = 0; +foreach my $file (@{$h{hostkey}}) { + if (not -e $file) { + ko "hostkey: $file defined in config but not found on disk!"; + next; + } + if (!open($fh_cmd, '-|', "ssh-keygen -lf $file.pub")) { + ko "hostkey: $file.pub can't be opened for verification!"; + next; + } + my $out = <$fh_cmd>; + close($fh_cmd); + chomp $out; + if (not $out =~ m{^(\d+) .+ \((.+)\)$}) { + ko "hostkey: $file can't be parsed ($out)"; + next; + } + my ($size, $algo) = ($1, $2); ## no critic (ProhibitCaptureWithoutTest) + if ($algo eq 'DSA') { ko "hostkey: DSA $size host key found, you should get rid of it" } + elsif ($algo eq 'RSA') { + $size >= 4096 and ok "hostkey: RSA $size host key found"; + $size < 4096 and ko "hostkey: RSA $size host key found, this is too small (< 4096)"; + $hasrsa = 1; + } + elsif ($algo eq 'ECDSA') { + ok "hostkey: ECDSA $size host key found"; + $hasecdsa = 1; + } + elsif ($algo eq 'ED25519') { + ok "hostkey: Ed25519 $size host key found"; + $hased25519 = 1; + } + else { + ko "hostkey: Unknown host key found ($file: $out)"; + } +} + +if (!$hasecdsa) { + if (grep { /_ecdsa_/ } @{$d{'hostkey'}}) { + ok "hostkey: You don't have any ECDSA key, maybe you don't like NIST curves, that's your right!"; + } + else { + ok "hostkey: You don't have any ECDSA key (but it's not supported by your SSH)"; + } +} + +if (!$hased25519) { + if (grep { /_ed25519_/ } @{$d{'hostkey'}}) { + wrn "hostkey: You don't have any Ed25519 key, generate one!"; + } + else { + ok "hostkey: You don't have any Ed25519 key (but it's not supported by your SSH)"; + } +} + +$hasrsa || wrn "hostkey: You don't have any RSA key, generate one!"; + +# loading known moduli +my $delimiterseen = 0; +my @xz; +my %knownmoduli; +my %foundmoduli; +open(my $fh_myself, '<', $0) or die $!; +while (<$fh_myself>) { + chomp; + $delimiterseen and push @xz, $_; + $delimiterseen++ if ($_ eq '__MODULI__'); +} +close($fh_myself); +my $decoded = decode_base64(join("\n", @xz)); +my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'unxz', '-c'); #TODO get rid of this call +print CHLD_IN $decoded; +close(CHLD_IN); +my $rawlist; +while () { + $rawlist .= $_; +} +waitpid($pid, 0); +my $child_exit_status = $? >> 8; +if ($child_exit_status != 0) { + ko "moduli: Error getting list of well known moduli"; +} +else { + foreach (split /\n/, $rawlist) { + chomp; + $knownmoduli{$_} = 1; + } +} + +# now moduli stuff +if (!open(my $fh_moduli, '<', "/etc/ssh/moduli")) { + ko "Couldn't open /etc/ssh/moduli to check it ($!)"; +} +else { + my %moduli; + my $atleast8191 = 0; + while (<$fh_moduli>) { + chomp; + /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/ or next; ## no critic (ProhibitUnusedCapture) + push @{$moduli{$5}}, $1; + $foundmoduli{$1} = 1; + } + close($fh_moduli); + foreach my $size (sort keys %moduli) { + my $count = scalar @{$moduli{$size}}; + my $nbknown = 0; + foreach my $mod (@{$moduli{$size}}) { + $nbknown++ if exists $knownmoduli{$mod}; + } + if ($size < 2047) { ko "moduli: found $count weak moduli of size $size ($nbknown well-known)" } + elsif ($size < 4095) { wrn "moduli: found $count medium moduli of size $size ($nbknown well-known)" } + else { ok "moduli: found $count strong moduli of size $size ($nbknown well-known)" } + $size >= 8191 and $atleast8191++; + } + if (not $atleast8191) { + wrn "moduli: found no moduli of size of at least 8191"; + } + my $wellknown = 0; + foreach my $mod (sort keys %foundmoduli) { + exists $knownmoduli{$mod} and $wellknown++; + } + if ($wellknown == 0) { + ok "moduli: None of your moduli is well-known (searched for " . (scalar keys %knownmoduli) . " well-known moduli), nice!"; + } + else { + my $nbmod = scalar keys %foundmoduli; + wrn "moduli: Found $wellknown/$nbmod well-known moduli in your file (" + . ($wellknown * 100.0 / $nbmod) + . "%), looked for " + . (scalar keys %knownmoduli) + . " well-known moduli"; + } +} + +sub check_config_value { + my $key = shift; + my $default = shift; + my $expected = shift; + + my $current_value = $default; + if (exists $h{lc($key)}) { + $current_value = $h{lc($key)}[0]; + } + else { + if (open(my $fh_config, '<', '/etc/ssh/sshd_config')) { + while (<$fh_config>) { + chomp; + /^\Q$key \E(.+)$/i or next; + $current_value = $1; + ok "config(debug): parsed from conf $key as '$current_value'"; + last; + } + close($fh_config); + } + } + + ref $expected ne 'ARRAY' and $expected = [$expected]; + if (grep { $current_value eq $_ } @$expected) { + ok "config: $key is set to '$current_value'"; + } + else { + wrn "config: $key is set to '$current_value', expected one of: " . join(',', @$expected); + } + + return 1; +} + +check_config_value 'UsePAM', 'no', [qw{ yes 1 }]; +check_config_value 'LoginGraceTime', 120, [(1 .. 120)]; +check_config_value 'MaxAuthTries', 6, [(1 .. 15)]; +check_config_value 'IgnoreRHosts', 'no', 'yes'; +check_config_value 'StrictModes', 'yes', 'yes'; +check_config_value 'PermitRootLogin', 'yes', [qw{ no without-password forbid-password }]; +check_config_value 'PermitEmptyPasswords', 'no', 'no'; +check_config_value 'UsePrivilegeSeparation', 'yes', [qw{ yes sandbox }]; +check_config_value 'PermitTunnel', 'yes', [qw{ 0 no }]; +check_config_value 'AllowAgentForwarding', 'yes', 'no'; +check_config_value 'AllowTcpForwarding', 'yes', 'no'; + +# check passwords +foreach (qx{passwd -Sa}) ## no critic (ProhibitBacktickOperators) +{ + /^(\S+)\s+(\S+)/ or next; + my ($login, $status) = ($1, $2); + if ($status eq "P") { + wrn "passwd: account $login has a usable password! maybe run usermod -L $login"; + } + elsif ($status eq "NP") { + wrn "passwd: account $login has an empty password!!! set one or run usermod -L $login"; + } + elsif ($status ne "L") { + wrn "passwd: account $login has a weird passwd status ($status)"; + } + elsif ($login eq 'root') { + ok "password: account $login has a locked password"; + } +} + +# get a list of valid shells +my %shells; +if (open(my $fh_shells, '<', '/etc/shells')) { + while (<$fh_shells>) { + chomp; + /^#/ and next; + $shells{$_} = 1; + } + close($fh_shells); +} + +# then check for ssh keys on valid shells +if (open(my $fh_passwd, '<', '/etc/passwd')) { + while (<$fh_passwd>) { + chomp; + my @tokens = split /:/; + my $shell = $tokens[6]; + next unless exists $shells{$shell}; + my $login = $tokens[0]; + + # has a valid shell + my $home = $tokens[5]; + foreach my $file ("$home/.ssh/authorized_keys", "$home/.ssh/authorized_keys2") { + next unless -e $file; + if (open(my $fh_auth, '<', $file)) { + while (<$fh_auth>) { + chomp; + /^\s*#/ and next; + /^\s*$/ and next; + my $short = $_; + length($short) > 99 and $short = substr($short, 0, 45) . '...' . substr($short, length($short) - 45); + inf "sshkey: login $login has a shell ($shell) and a key: $short"; + } + close($fh_auth); + } + } + } + close($fh_passwd); +} + +# check umask +my $umaskFound = undef; +if (open(my $fh_login, '<', '/etc/login.defs')) { + while (<$fh_login>) { + /^UMASK\s+(.+)/ or next; + if ($1 ne '027' or not defined $umaskFound) { + $umaskFound = $1; + } + } + close($fh_login); + if (not $umaskFound) { + wrn "umask: no value found, expected 027 in /etc/login.defs"; + } + elsif ($umaskFound ne '027') { + wrn "umask: bad value found ($umaskFound), need 027 in /etc/login.defs"; + } + else { + ok "umask: expected 027 value found"; + } +} + +if (open(my $fh_pam, '<', '/etc/pam.d/common-session')) { + my $umaskOk = 0; + while (<$fh_pam>) { + /^\s*session\s+optional\s+pam_umask\.so\s+umask=0?027/ or next; + ok "umask: correct umask found in pam.d"; + $umaskOk = 1; + last; + } + close($fh_pam); + if (not $umaskOk) { + wrn "umask: no pam.d umask configuration found or bad one"; + } +} + +if (defined $generate_moduli and $generate_moduli > 0) { + my ($fh, $file_unchecked) = tempfile("moduli.unchecked.$generate_moduli.XXXXXX", SUFFIX => '.txt', TMPDIR => 1); + local $SIG{'INT'} = sub { unlink($file_unchecked); }; + print "Generating candidates of size $generate_moduli...\n"; + system("nice ssh-keygen -G $file_unchecked -b $generate_moduli"); + print "Validating generated candidates of size $generate_moduli...\n"; + system("nice ssh-keygen -T /tmp/moduli.checked.$generate_moduli.pid$$.txt -f $file_unchecked"); + unlink($file_unchecked); +} + +__END__ + +__MODULI__ +/Td6WFoAAATm1rRGAgAhARYAAAB0L+Wj4H38EaRdAAUJiSlag5YALZsrn4vX1kL+swvtsDNqbhi5jgRqer9uFoOL/l1RVa2n1UisIBkstmyQX2e0I3/ERtnaY09bixqcdtyodOdXMaBU4xn+59EBJhAKyNi8IYwFkLXs92s4o3 +VGs0BSb5HhIv+9KorGOzj/SgZG35nSVlpby5g+GErLTzBQlY4tX9Rfn3Sdvd0U6e3rhHAJuEU9npV7+/rynSZ+8Raob0IgD1DOs39p0S+BLvNF0iwo4cYokP4TJ7/ZiVYApfpuZsDmPQh1IW2gG1aw76Jg7NiJb2GTP5DpZkm+ +1PzfVeF+sgB4IIMFplEp87/YEVFVYoutQ4WL7QSsFxZKr6UWkEJ02UE87wc2V/MEmkbFDDQi0qfRdZep7FmdE7DAsqHjUuKQxICnSDfwNvm7ZKwbUvdQZTdOZaTsrK++jRdRUYtCyp67HQ7rkQslbvdC/4E2unplRBFAvFSj6I +Z503HfCO6x0K+akz39ptUmSfaVwM3mjIpQ82qGtL/atu87hB0mT0MwpIkrW8BRwZwV5H21wEfz2A3tSDsQ3/n5OlGtH91yso2IYxHLC0ggd2mCSnjB+u4pUDNRpHxMUqUgv9pyyYAm3OTXT3zDu38EKBXt01WBHUPiLLRgRRb5 +1FdpCWMptRV2zrdXJ8e2nugOba5LHdOWHHbvUBkGo8P0a4D8OTw7C9Vag/Ezvp+zW2W5y6B0PLi43UspJT1zU+BxZDoohV0ySdX6AQBZnfmEem84IfUB0m8VFUxplhbMoUPYxWueUH5Eoe3bt4yLFSspdYBxXGLmlyi5v6rORa +5NBmoXoUtUxHgPn/+p6Y2DHzULDB/MBnaLNBM1OJ9h1aftUeYq6SD9+KuaMaocv9EbfESOPj3AuEPX9afUdlNXgeJY5nmAQ0+rneIeB0xjR/lD5+ReUZTuZQgn/NO2a5glz7XRCE/HET082/sOFuFmBDbBkgZz/jKSrhbKJfZp +WAi7Q+GdjHpmiQ1fRmnr0dcuOs1uJ0DqR+fuxjQLWbXerx1qvtJdGwF2cIOpUQfXZrI0I5ZSXosUZoh0roGb7EG1kse4Pu6PU1Q8dZBsCX77keX/aiGnoamyKpwyWaF6VTBZIlNtbHzXNHNd36u2qHtfM5Fg+Vr6z7Y3Kz50E8 +H1ALKRjrHX4zHP+AS0KdguYLVTW1urIgFbrd/34e+3k4PY7Kr7A3DFjjCx/T3vAfiB63wGzo8QJ3aEDIfEX6A+XcMEvfxx/qdjJCZoT5/b6phCtIQCxJkxU/6ZcTs3yrRkKskZZO4JE7iB5dZwiPXznB0Zmoow96r7zKQSL4va +6ahcMXHyPMpD0MP/n4rnMm7qxLcrS/TFSMoS4uNalS3HLlmMv40brBlnpZcfbk+iuW8P2xervK8WlzI9Xi43Xy00iZDC/pwPC7pGiGqePawE6AhK46XXWbj/Tujz+wRDw3OqdvTd1sO0grnQd4Rx8dUbgQ9aQk8b2jjTyd2Hhk +/qUVuTjoCwvLq60ZFjPjN4Z5S/TGwbddkOnMOgqRwYUdiQyj2G9HJZjakO3/uW6Ud5VTMbOIH5VYnb4iQCaw/3IpknDrvkWdb3Lj8eibgUUNzYglLrmr6udvhAWw5CQbMhYDgqFVkElnQv04Qji+2NhSsuUMhDxzMkmfvqjNDs +TiSX33KZZC9wgd15yTw68hhcApuxZrdkuwjmaINGgs92T1hE/0NW5ZafpCyijtdWBY7O8fhURGQbxIUBVu718Z9EjXigX1kuPXVmqHspiyJo5T8/o02Q9eoQTeNZIcLHwZediHS0dt0lrZLouDKx2RcWihAoxX99F9xiJ35i6C +EmncZVrHnXDCnWDJPyVRUI4cmYlGcgITGHFOaK9gtoo/IxfmCTCXsreuz+mXjMqlOSMvMYeprFsKiVFdq105HdLMXb2kpyXIj5hWAefggV59EVCcbMJgY8Nh9sOlzRvKoGEfj+9ZdiyqOduxoIAoGUOC52K2v7eIhiG5Z19qiT +QmXmDbPPOVYJcuxUbeyQIBxrHCOukEVxkPuCyffAjEf2oYkyHpH21ngk+roKkOhQJWiGwwUDUxXZp5R7iVhsPk5u0uMIzTrGugRwrHEZGeIKIGwvJ5GbyYTraI0qNYPaK5llz/MpFHdlqAtXG2qfL4tbCr/trrOFgQAC9y117v +8pzVOygwl4wmQVBCMMyI99mGTtnbkwRwRhA4t4GwP+cKXMo4+smRVvAlxVWAV++wCCZTfSu/FQdviVDxAbNPUQoEvAl8KSGWszSDWxnrffwSRafMRA3W3GAJt8ExXpp3jJmYqCINCB3vzX4/LWL6ypsuHPd63mgPS0L2sIR/zE +ChMtv9kTCh/Q/9hk8egcpQX8UG2WaBm+BE7UeuY0nid0y9sUxlPlKJcl2iGbMMOPIyGABZ0OzWXk17ta1CeVCAjXByIkbeoIwwrT/6XzVo4bodrM5iLAMNOiMDBznQj2I/UcWfvRVHraXjPG/b+NQAslEUyZdoSB78U5yv2NMG +eXIdlQ3eeJNJAfHAG4G5wdjJ0qNzdMDyaYhXfWgvkj7A2lYtKDdPwZChm+Q2EblxPN7DR9jUNhw7JhXUNa5ASCTdw0cOzvV1FYyT832us3/FYktRSGUbT+5nbIB+IZA82trUj7Awui6bg1ew0JKPlHsFeDugY6GLQrhtgE3ZDX +XoPcDXEPjTlJ2eR94k2ala0coe61I+0OfQ/Xl9ocicDpSXE97GUqqA/QfyCbDNv/hRd+75Nk+FW1Gkpi2iuy2/vR6BL0daxmAi428JQscKBsEGSjvPn11kmLp0UnHiEPkaTRm4GrrV+07tfOaZnIlKxs0MUFnI4dhJdB2xX0hi +b9FAFMzsP9BiBp6ZwEbjsstX0W3VCeGS3OeVaWlP7DULGE7agS9d9HkKZuw5mS2fmvO0c9HNpGrVoqDp5xfcggLVW744NDMPAkuRWIx1t6Exz1rzpDfZV0MN9PZf5gCg/TzOZcTtagwaITWCM9/J1hrnNueH5WbStDo4DwGpqD +LuWQoQzRdk5mfmzFUHbidczooLsjqiYRK9fwwztT+A0la/yYvMobR4vLoENgyNSCVF1Ei4bPXwL+VawqN6WYK5rK090gyhsDsVgzgNYbkV4urRb2+cDeoHN7o3nvUcj99Cozqv8zjD/30M+x34t/l6jpfrvy/7IJczOOCK82Qu +XvA97fxvLgBmtL1q7KPrb5LackAyRfItPtxZ1aM/vHWtHqsSI+l0BwdsqBeJe6cGWib6jWCEj2CWPC3D+X3fkte1qhHHSvHGFprNq15hRUp5MSYkNpI4OYrRj5hBbYSnTrYizbrIIssfrnF6ynEhGzr12pJCxAbK0PVfvaUkN1 +NmMZfgdsk5Zf/nVhsT3UT3mWewNHqAWqG5yQizXhSNOGMAzzVjP/Xy1Uz1t9Al4BPc+LS80/6Q9KGokMx9DS02jqNWuwTJUVqJaoNcbvL8UREzGB8Ndt88QlBvKZdqqn1s9aUSA6e0SQnwwR05KeniCz7HJf2sPo06WrHMt2p9 +tm/CAobg3vCP3ZimViSe68KxUM6LqXir/pCAcCklCoJEqhLKzLH/lrEE7IdWlbhgXVf4dENehFNzLwe05yxKX+jWvkEWG0z9C9zsgOTUjxixtoOnpszpgnayyTI3tcSOsPWZJHU88Nx5GM1VHxtFF93EvBJza90hZath/DhhRw +h6hZ8OWtmtIlWVGi/6oerhBF3yJxKB6VCaWyqHyTbiA722ADq+h3/ul99A57Rk1vzN0/neDJb0YWrzk1WofrFY+J44NtO7cArHLd2UKdbbLR1jMYax0wvu5gkdlJh2FCg5oJne0ZRQm+y8ScWyqk4dJbmw152MScHpqVFdrt7d +qWjusb94MRfyqV5ppqb3A5KJ4cdXPs+k30aAxzyMVmZbSGHL3TbwcduxI/aY3UNOxTXE5+Co1m78XdzmDTTg+gi1Udmv9VNl2+r4rn8pbghw6wcZlWyMSeZYKflfqu8jF5kRM0mq3tgF02bmmb8FzsXEC5okJi/iJkuQFzK/y9 +y4mGUa1AowA4p2wBtq4xH/Dv0r+yirirSAFSJGppGC5CVxlG4vg+3+M1lutSNunBLfjXPplFdpdzad6lbDuQbBVXK80km8m29OXYt27FF76o3kOjkdb7adbbKZzK3eY8CSGuBZjN6X0DMBM5KcJQOo4XtNeQhZzd3px4V0RqmB ++NyMaC9EcAdFEJZ6K8QJ0S7HSXOfVRMS41TSXkz/L1cPTuRgbb/y/F+ona91ag3u6dNH2Mpw0FQMYg6hrtR8pd2lv0zaWbWNUffl/krQvdzENGKsW6zRsO7z0OM9ZikfQEnEo0RNj0Jn8r4oqWaf1e+BgvIxmSG08JtDZjo0f4 +SM7gB/0oTGYzCysqxmdJ6vnv5kbVtm+KszveBB77PNDcj1MGeVG38LM1Hl/h4HkGt+1zDy87lc8jRbA6gcvYqKHv9ls651aV9d6qg23+K4rGgH0mCeEhCySLC06n+/hSwzmU8tOhpp8nSy3lBa6CeHnDYRyKSxPMtVdZD/rS1o +YCVr2BAZU5s2GY0AZgiAhprEpQqkfPmSiMXthV8DXmOb4P10T62GJfqgjsDbjg5LoYS4sl4OvsJ3LC8bCAo2nsqTGrb4CE+zbmn9L3MAnNYKHAnnhK/CZILBaCDalt1pSWiogkEOrtWjNZ/mX/OCDWAF1/kkMDS0trrzlNDQwn +LTLwmkkWBpqzzIiE5UJcMQA35+/gjbvQBjG3t3K5Q48ee44pAYcaVFCm6sCvzjZl5GXpQZv9XCNqXf+PjuEIsnCUodA8tmvV9nY3LyTmLDM2XZ8SmEQ/NbwLbpfM1l25mFLLTbfIXWO7WVEb7gtuHGmqPijGpgZh/Ubhc91+Lp +EgbEGRyJJKsUoPf/cie49oYurfwWwBB3qppPwaCtyRHLKIgJHJZXtf6M97ZpQW69DjbDgileth/6il6GbBxK/vrdQ52McwmLpnW1IhsymO0wq2OLt0tWxBVODaQPDtOKt/P49rKir8DL+3sM0XnjvTiI4XENwxi7qavLqaSNnB +4irzcrI+fEI4RSnZRAsGaPiRlLxism1JsDSzhgoatfXYKVYZvzXFHXpos+uXdTAW4Rb1ymu/TOKDwCKgUTm6i/4RQowPr5Xt4aOgAZS1TGqDCSguOYZDN/dQiVpFhDO8mA0esB5YITcE8ATXzMx40D8wMbJ28HVABotdWYHlY2 +2/nmlQq4LeGoFXQHoZD4osxXyqOR46R+IbmjbndwmJl5XSZMJmUSboWdGKIs0D1E+cbtxYBHKLasupXOeEGmSxCF4iYGQjSmHT060e1otJDgv9/QA+imEOA9qiSLd1N3ZPQGeCt8WwV9Qqs90+1y9c3gtJRgaM6pEA7Se3sYzS +gTRKR6SDFN/uQo8MxATwGrC5chJLGH80TZj2v2F4I96Y2Xf20B5HKTNXCExmxw4xQEjZfqhrulblZuipuGD/lRCPAyNUEPMOLdD2veLeWIRLOD+N8i1gaC7jubmmLjkLYKbNKlpBhynwPfGzn2OL76zGXQRksFgdSNAhXmp5A6 +o/rimulgO4pbCJ1Dkheu/fjpIUAZfryy+umwDoXwgkrES5++a5YLz4FBzVw9avP7T0ykrK5Bw/Ld1MoXM+rkp5JfHMFhTbicKndVKk5GeJ3WbPhjM1yaP+X/ac0nkQ1oWYXBjmoCbGgFw6O3Zhv5PL7+gtetsCWif4AQkLxQFo +5OoTvtDspWc7IBpQEEAp81St2VbgfSMzGVCWUi+LC/INMBk0z45hjiDqPZXRCJfwdFODahXjDCPkYuHfBaUOlvkwHzZ6pftxJ7tmBB7cYLWhu/3cC39o3eAd3G3xUGoeF8dODsS8yNrX4PS4Vk6kzuvTvgY/KgIAC5Y+IC2P1Q +/8RDaF4VCznj3IG4AiFZgsJv+4UbLzHiYCjqPfHyNxZY3p7E77JknAYXAJXAf/LHQQBsff4rbuYgCScaJ7wLC4zSnVam4rekpBOTH+QS5+LFgqOAAAOphsNU8vQF4AAcAj/fsBALf8WoCxxGf7AgAAAAAEWVo= + diff --git a/bin/admin/fix-group-gid.sh b/bin/admin/fix-group-gid.sh new file mode 100755 index 0000000..7d92417 --- /dev/null +++ b/bin/admin/fix-group-gid.sh @@ -0,0 +1,128 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +MINGID=10000 + +if [ -n "$2" ] || [ -z "$1" ] ; then + echo "Usage: $0 " + exit 2 +fi + +fail() +{ + echo "Error, will not proceed: $*" + exit 1 +} + +really_run_commands=0 +something_to_do=0 + +_run() +{ + something_to_do=1 + if [ "$really_run_commands" = "1" ] ; then + echo "Executing: $*" + read -r ___ + "$@" + else + echo "DRY RUN: would execute: $*" + fi +} + +find_next_available_gid() +{ + nextgid=$((MINGID + 1)) + while getent group "$nextgid" >/dev/null; do + nextgid=$((nextgid + 1)) + done + echo $nextgid +} + +change_gid() +{ + group="$1" + type="$2" + + maingroup=$(echo "$group" | sed -re 's/-(aclkeeper|gatekeeper|owner)//g') + + if [ "$type" != secondary ]; then + getent passwd "$group" >/dev/null || fail "user $group doesn't exist" + fi + if [ "$type" != secondary ]; then + getent group "$group" >/dev/null || fail "group $group doesn't exist" + else + getent group "$group" >/dev/null || return + fi + + oldgid=$(getent group "$group" | awk -F: '{print $3}') + + [ "$oldgid" -ge "$MINGID" ] && return + + newgid=$(find_next_available_gid) + + _run group_change_gid_compat "$group" "$newgid" + tocheck="" + for dir in "/home/$group" "/home/keykeeper/$group" "/home/$maingroup" "/home/keykeeper/$maingroup"; do + test -d "$dir" && tocheck="$tocheck $dir" + done + if [ -n "$tocheck" ]; then + # shellcheck disable=SC2086 + _run find $tocheck -gid "$oldgid" -exec chgrp "$group" '{}' \; + fi + + if command -v getfacl >/dev/null && command -v setfacl >/dev/null; then + ( cd / ; _run sh -c "getfacl /home/$maingroup 2>/dev/null | sed -re 's/:$oldgid:/:$group:/' | setfacl --restore=-" ) + fi +} + +batchrun() +{ + something_to_do=0 + change_gid "key$from" + change_gid "key$from-gatekeeper" secondary + change_gid "key$from-aclkeeper" secondary + change_gid "key$from-owner" secondary +} + +main() +{ + from=$(echo "$from" | sed -re 's/^key//') + + if [ "$from" = "keeper" ] || [ "$from" = "reader" ]; then + echo "$from: special group, skipping." + return + fi + + really_run_commands=0 + batchrun + + if [ "$something_to_do" = 0 ]; then + echo "$from: nothing to do." + return + fi + + echo + echo "$group: OK to proceed ? (CTRL+C to abort). You'll still have to validate each commands I'm going to run" + # shellcheck disable=SC2034 + read -r ___ + really_run_commands=1 + batchrun + echo "$group: done." +} + +if [ "$1" = "ALL" ]; then + groups=$(getent group | grep "^key" | cut -d: -f1 | grep -Ev -- '-(aclkeeper|gatekeeper|owner)$') + for from in $groups + do + main + done +else + from="$1" + main +fi + diff --git a/bin/admin/fixrights.sh b/bin/admin/fixrights.sh new file mode 100755 index 0000000..be342dc --- /dev/null +++ b/bin/admin/fixrights.sh @@ -0,0 +1,63 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +action_doing "Adjusting rights on $basedir" +if [ ! -w "$basedir" ]; then + action_error "$basedir is not writable" + exit 1 +fi + +# we must ensure that all basedir parents are at least o+x +parent="$basedir" +while [ -n "$parent" ]; +do + chmod o+x "$parent" + parent=$(echo "$parent" | sed -re 's=/+[^/]+$==') +done + +find "$basedir" -name .git -prune -o -print0 | xargs -r0 chown "$UID0:$GID0" +chmod o+x "$basedir" +find "$basedir" -name .git -prune -o -type d -print0 | xargs -r0 chmod 0755 +find "$basedir" -name .git -prune -o -name contrib -prune -o -type f -print0 | xargs -r0 chmod 0644 +find "$basedir"/bin/ ! -name "*.json" -print0 | xargs -r0 chmod 0755 +chmod 0644 "$basedir"/bin/dev/perlcriticrc +chmod 0700 "$basedir"/bin/admin/install +chmod 0700 "$basedir"/contrib +chmod 0700 "$basedir"/bin/sudogen + +while IFS= read -r -d '' file +do + filemode=$(awk '/# FILEMODE / { print $3; exit; }' "$file") + fileown=$(awk '/# FILEOWN / { print $3":"$4; exit; }' "$file") + if [ -z "$filemode" ] && [ -z "$fileown" ]; then + action_error "Missing info for $file" + else + action_detail "$filemode $fileown $file" + chmod -- "$filemode" "$file" + chown -- "$fileown" "$file" + fi +done < <(find "$basedir/bin/helper" -type f -print0) + +chmod 0755 "$basedir"/docker/entrypoint.sh \ + "$basedir"/tests/functional/docker/docker_build_and_run_tests.sh \ + "$basedir"/tests/functional/docker/docker_build_and_run_tests_all.sh \ + "$basedir"/tests/functional/launch_tests_on_instance.sh \ + "$basedir"/tests/functional/docker/target_role.sh \ + "$basedir"/tests/functional/docker/tester_role.sh \ + "$basedir"/tests/functional/fake_ttyrec.sh \ + "$basedir"/tests/unit/run.pl + +while IFS= read -r -d '' plugin +do + groupname=$(basename "$plugin") + getent group "osh-$groupname" >/dev/null || continue + chown "$UID0:osh-$groupname" "$plugin" + chmod 0750 "$plugin" +done < <(find "$basedir/bin/plugin/restricted/" ! -name "*.json" -print0) + +action_done "" diff --git a/bin/admin/grant-all-restricted-commands-to.sh b/bin/admin/grant-all-restricted-commands-to.sh new file mode 100755 index 0000000..7296595 --- /dev/null +++ b/bin/admin/grant-all-restricted-commands-to.sh @@ -0,0 +1,60 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +account="$1" +if [ -z "$account" ] ; then + echo "Usage: $0 ACCOUNT" >&2 + exit 1 +fi + +action_doing "Granting all restricted commands to $account" + +if ! getent passwd "$account" >/dev/null ; then + action_error "Account $account not found" + exit 2 +fi + +if ! getent passwd "$account" | grep -q /osh.pl$ ; then + action_error "Account $account doesn't seem to be a bastion account" + exit 4 +fi + +if ! cd "$basedir"/bin/plugin/restricted; then + action_error "Error trying to access the restricted plugins directory" + exit 3 +fi + +allok=1 +for group in auditor $(ls) +do + echo "$group" | grep -Fq . && continue + group="osh-$group" + if getent group "$group" >/dev/null ; then + if getent group "$group" | grep -qE ":$account$|:$account,|,$account,|,$account$" ; then + action_detail "Account was already in group $group" + else + if add_user_to_group_compat "$account" "$group" ; then + action_detail "Account added to group $group" + else + action_error "Error adding user... continuing anyway" + allok=0 + fi + fi + else + action_error "group $group doesn't exist, ignoring" + allok=0 + fi +done + +if [ "$allok" = 1 ] ; then + action_done "$account has been granted to all restricted commands" + exit 0 +else + action_warn "Got some errors adding $account to all restricted commands" + exit 1 +fi diff --git a/bin/admin/install b/bin/admin/install new file mode 100755 index 0000000..079ce82 --- /dev/null +++ b/bin/admin/install @@ -0,0 +1,1294 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +declare -A opt + +TTYREC_VERSION_NEEDED=1.1.6.1 + +set_default_options() +{ + opt[modify-banner]=0 + opt[modify-sshd-config]=0 + opt[modify-ssh-config]=0 + opt[modify-motd]=0 + opt[modify-umask]=0 + opt[modify-pam-sshd]=0 + opt[modify-pam-lastlog]=0 + opt[remove-weak-moduli]=0 + opt[regen-hostkeys]=0 + opt[logrotate]=1 + opt[overwrite-logrotate]=1 + # special case: + # If $autodetect_startup_system is 1, we'll autodetect whether we will install the + # systemd units (preferred) or the init.d files. + # Any specification of a --[no-]init or --[no]systemd-units option by the user inhibits + # this behavior. So even if both options below default to 0, one of those will in effect + # be set to 1 if $autodetect_startup_system is 1 + autodetect_startup_system=1 + opt[init]=0 + opt[systemd-units]=0 + # /special + opt[profile]=1 + opt[cron]=1 + opt[overwrite-cron]=1 + opt[syslog-ng]=1 + opt[overwrite-syslog-ng]=1 + opt[migration-grant-aclkeeper-to-gatekeepers]=0 + opt[wait]=1 + opt[check-ttyrec]=1 + opt[install-fake-ttyrec]=0 +} +set_default_options + +show_help=0 +[ -z "$1" ] && show_help=1 +nothing=0 + +while [ -n "$1" ]; do + if [ "$1" = "--new-install" ]; then + set_default_options + opt[modify-banner]=1 + opt[modify-sshd-config]=1 + opt[modify-ssh-config]=1 + opt[modify-motd]=1 + opt[modify-umask]=1 + opt[modify-pam-sshd]=1 + opt[modify-pam-lastlog]=1 + opt[remove-weak-moduli]=1 + opt[regen-hostkeys]=1 + elif [ "$1" = "--minimal" ] || [ "$1" = "--nothing" ]; then + set_default_options + opt[logrotate]=0 + opt[overwrite-logrotate]=0 + autodetect_startup_system=0 + opt[profile]=0 + opt[cron]=0 + opt[overwrite-cron]=0 + opt[syslog-ng]=0 + opt[overwrite-syslog-ng]=0 + [ "$1" = "--nothing" ] && nothing=1 + elif [ "$1" = "--managed-upgrade" ] || [ "$1" = "--puppet" ]; then + set_default_options + opt[logrotate]=1 + opt[overwrite-logrotate]=0 + autodetect_startup_system=1 + opt[profile]=0 + opt[cron]=1 + opt[overwrite-cron]=1 + opt[syslog-ng]=0 + opt[overwrite-syslog-ng]=0 + elif [ "$1" = "--upgrade" ]; then + set_default_options + else + foundoption=0 + for allowedopt in modify-banner modify-sshd-config modify-ssh-config modify-motd modify-umask \ + modify-pam-lastlog remove-weak-moduli regen-hostkeys overwrite-logrotate overwrite-cron \ + overwrite-syslog-ng logrotate cron syslog-ng migration-grant-aclkeeper-to-gatekeepers \ + init systemd-units profile modify-pam-sshd wait check-ttyrec install-fake-ttyrec + do + if [ "$1" = "--no-$allowedopt" ]; then + opt[$allowedopt]=0 + foundoption=1 + elif [ "$1" = "--$allowedopt" ]; then + opt[$allowedopt]=1 + foundoption=1 + fi + if [ "$1" = "init" ] || [ "$1" = "systemd-units" ]; then + # see "special case" comment above for more information + autodetect_startup_system=0 + fi + done + if [ "$foundoption" != 1 ]; then + echo "$0: Unrecognized option '$1'" + show_help=1 + fi + fi + shift +done + +if [ "$show_help" = 1 ] ; then + cat <=2.21.00): this option grants the aclkeeper right to all preexisting gatekeepers, + this helps ensuring a smooth transition from the users perspective + +EOF + exit 1 +fi + +if [ "${opt[wait]}" = 1 ]; then + action_doing "Touching lockfile to suspend sync, and waiting 3 seconds to ensure it has been picked up..." + # shellcheck disable=SC2064 + trap "rm -f $LOCKFILE" EXIT HUP INT + touch "$LOCKFILE" + sleep 3 + action_done +fi + +if [ "${opt[install-fake-ttyrec]}" = 1 ]; then + action_doing "Installing fake ttyrec (use this only for tests!)" + if [ ! -e "/usr/bin/ttyrec" ]; then + install -o "$UID0" -g "$GID0" -m 0755 "$basedir/tests/functional/fake_ttyrec.sh" "/usr/bin/ttyrec" + action_done + else + action_na + fi +fi + +if [ "${opt[modify-ssh-config]}" = 1 ] || [ "${opt[modify-sshd-config]}" = 1 ] ; then + action_doing "Find which ssh/sshd config templates to install on $OS_FAMILY $LINUX_DISTRO $DISTRO_VERSION" + short_suffix_name=$(echo "$LINUX_DISTRO$DISTRO_VERSION_MAJOR" | sed -re "s/[^a-z0-9]//") + filesuffix=default + if [ -e "$basedir/etc/ssh/sshd_config.$short_suffix_name" ] && [ -e "$basedir/etc/ssh/ssh_config.$short_suffix_name" ]; then + filesuffix=$short_suffix_name + elif [ "$OS_FAMILY" = Linux ]; then + if [ "$LINUX_DISTRO" = ubuntu ]; then + if [ "$DISTRO_VERSION_MAJOR" -le 14 ]; then + filesuffix=debian7 + elif [ "$DISTRO_VERSION_MAJOR" -le 16 ]; then + filesuffix=debian8 + else + filesuffix=debian10 + fi + elif echo "$DISTRO_LIKE" | grep -q -w suse; then + filesuffix=opensuse15 + fi + fi + + action_done "Will use the $filesuffix templates" + + if [ "${opt[modify-ssh-config]}" = 1 ] ; then + action_doing "Install hardened configuration for ssh to $SSH_DIR/ssh_config" + install -o "$UID0" -g "$GID0" -m 0644 "$basedir/etc/ssh/ssh_config.$filesuffix" $SSH_DIR/ssh_config + fi + if [ "${opt[modify-sshd-config]}" = 1 ] ; then + action_doing "Install hardened configuration for sshd to $SSH_DIR/sshd_config" + install -o "$UID0" -g "$GID0" -m 0644 "$basedir/etc/ssh/sshd_config.$filesuffix" $SSH_DIR/sshd_config + fi + action_done +fi + +if [ "${opt[modify-banner]}" = 1 ] ; then + action_doing "Install default sshd banner" + install -o "$UID0" -g "$GID0" -m 0644 "$basedir/etc/ssh/banner" $SSH_DIR/ + action_done +fi + +if [ "${opt[modify-motd]}" = 1 ] ; then + action_doing "Empty the /etc/motd file" + if [ -f /etc/motd ] && [ ! -s /etc/motd ] ; then + mv /etc/motd /etc/motd.bastion-backup + cat /dev/null > /etc/motd + chmod 644 /etc/motd + action_done + else + action_na "File already empty or non existing" + fi +fi + +if [ "${opt[regen-hostkeys]}" = 1 ] ; then + action_doing "Change sshd host keys (this can cake a while)" + rm -f $SSH_DIR/ssh_host_{dsa,rsa,ecdsa,ed25519}_key{,.pub} + ssh-keygen -q -t rsa -b 4096 -N '' -f $SSH_DIR/ssh_host_rsa_key >/dev/null + ssh-keygen -q -t ecdsa -b 521 -N '' -f $SSH_DIR/ssh_host_ecdsa_key >/dev/null || true + ssh-keygen -q -A >/dev/null || true + action_done + + action_doing "Restart sshd" + set +e + ret=-1 + if [ -e /etc/rc.d/sshd ] ; then + /etc/rc.d/sshd restart; ret=$? + elif [ -e /etc/init.d/ssh ] ; then + /etc/init.d/ssh restart; ret=$? + fi + if [ $ret -eq 0 ]; then + action_done + else + action_error "You might want to check if the sshd config is valid!" + fi + set -e +fi + +if [ "$nothing" = 0 ]; then + action_doing "Add .ssh in /etc/skel if needed" + if [ ! -d /etc/skel/.ssh ] && [ -d /etc/skel ]; then + if mkdir /etc/skel/.ssh; then + action_done + else + action_error "Couldn't create /etc/skel/.ssh, nevermind, proceeding" + fi + else + action_na + fi + + action_doing "Create $BASTION_ETC_DIR if needed" + if [ -d $BASTION_ETC_DIR ]; then + action_na + else + mkdir $BASTION_ETC_DIR + action_done + fi + + chmod 751 $BASTION_ETC_DIR + + # to make the otp pam module of our default config happy + action_doing "Create /var/otp if needed" + if [ ! -d /var/otp ]; then + if mkdir /var/otp; then + action_done + else + action_error + fi + else + action_na + fi + + # remove obsolete logrotate files if needed + if [ "${opt[logrotate]}" = 1 ]; then + action_doing "Remove obsolete logrotate files..." + at_least_one_changed=0 + for obsolete in osh-proxy-http osh-update-active-users + do + if [ -e "$ETC_DIR/logrotate.d/$obsolete" ]; then + at_least_one_changed=1 + rm -f "$ETC_DIR/logrotate.d/$obsolete" + fi + done + fi + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # remove obsolete cron files if needed + if [ "${opt[cron]}" = 1 ]; then + action_doing "Remove obsolete cron files..." + at_least_one_changed=0 + for obsolete in osh-backupAclKeys osh-compressOldSqlite osh-encryptRsyncTtyrec \ + osh-lingeringSessionsReaper osh-orphanedHomedir osh-pivGraceReaper \ + osh-protectLogs osh-rotateTtyrec osh-activeUsers + do + if [ -e "$ETC_DIR/cron.d/$obsolete" ]; then + at_least_one_changed=1 + rm -f "$ETC_DIR/cron.d/$obsolete" + fi + done + fi + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + action_doing "Move $BASTION_ETC_DIR/proxy-http.conf if needed" + if [ -f $BASTION_ETC_DIR/proxy-http.conf ] && ! [ -e $BASTION_ETC_DIR/osh-http-proxy.conf ]; then + mv $BASTION_ETC_DIR/proxy-http.conf $BASTION_ETC_DIR/osh-http-proxy.conf + action_done + else + action_na + fi + + action_doing "Move $BASTION_ETC_DIR/sync-watcher-rsync.filter if needed" + if [ -f $BASTION_ETC_DIR/sync-watcher-rsync.filter ] && ! [ -e $BASTION_ETC_DIR/osh-sync-watcher.rsyncfilter ]; then + mv $BASTION_ETC_DIR/sync-watcher-rsync.filter $BASTION_ETC_DIR/osh-sync-watcher.rsyncfilter + action_done + else + action_na + fi + + action_doing "Move $BASTION_ETC_DIR/sync-watcher.sh if needed" + if [ -f $BASTION_ETC_DIR/sync-watcher.sh ] && ! [ -e $BASTION_ETC_DIR/osh-sync-watcher.sh ]; then + mv $BASTION_ETC_DIR/sync-watcher.sh $BASTION_ETC_DIR/osh-sync-watcher.sh + action_done + else + action_na + fi + + dirstocheck='bastion' + [ "${opt[logrotate]}" = 1 ] && dirstocheck="$dirstocheck logrotate.d" + [ "${opt[cron]}" = 1 ] && dirstocheck="$dirstocheck cron.d" + [ "${opt[syslog-ng]}" = 1 ] && dirstocheck="$dirstocheck syslog-ng/conf.d" + for subdir in $dirstocheck + do + # don't try to copy file in nonexistent dirs (i.e. syslog-ng if rsyslog is installed) + # our own specific dirs have already been created above, so they exist + action_doing "Check files in $ETC_DIR/$subdir..." + [ -d "$ETC_DIR/$subdir" ] || continue + + for file in "$basedir/etc/$subdir"/*.dist ; do + destfile="$ETC_DIR/$subdir/$(basename "$file" .dist)" + if [ -e "$destfile" ]; then + # if the target already exist, check if we're asked to overwrite it + if [ "$subdir" = "logrotate.d" ] && [ "${opt[overwrite-logrotate]}" = 1 ]; then + : # we'll overwrite + elif [ "$subdir" = "cron.d" ] && [ "${opt[overwrite-cron]}" = 1 ]; then + : # we'll overwrite + elif [ "$subdir" = "syslog-ng/conf.d" ] && [ "${opt[overwrite-syslog-ng]}" = 1 ]; then + : # we'll overwrite + else + # in all other cases, don't overwrite + action_detail "... won't overwrite $destfile" + continue + fi + action_detail "... we will overwrite $destfile" + elif [ "$(basename "$file")" = "osh-encrypt-rsync.conf.dist" ] || [ "$(basename "$file")" = "osh-backup-acl-keys.conf.dist" ]; then + # special case for those files: if we have the $file.d dir available, don't do anything + if [ -d "$destfile".d ]; then + action_detail "... won't copy file to $destfile as we have $destfile.d" + continue + fi + fi + # if the dest file didn't exist, or it does but we've been asked to overwrite it, do it + # copy our template .dist to proper location in system + action_detail "... create $destfile" + install -o "$UID0" -g "$GID0" -m 0644 -b "$file" "$destfile" + # actually don't do a backup for cron files: we would get double-executions... + [ "$subdir" = "cron.d" ] && rm -f "$destfile"\~ + + # special case if the file contains %RANDOMX%N:M%, with X between 1 and 9, + # we replace it by a random number between N and M (for crons) + if grep -qE '%RANDOM[1-9]%[0-9]+:[0-9]+%' "$destfile"; then + for i in $(seq 1 9) + do + placeholder=$(grep -Eo "%RANDOM$i%[0-9]+:[0-9]+%" "$destfile" | head -n1) + [ -n "$placeholder" ] || continue + # we have a match, compute the random and do the replace + n=$(echo "$placeholder" | cut -d% -f3 | cut -d: -f1) + m=$(echo "$placeholder" | cut -d% -f3 | cut -d: -f2) + if [ $(( m-n )) -eq 0 ]; then + action_detail "... we would divide by zero! fallback to a non-random random, such as $n" + random=$n + else + random=$(( ( 0x$(echo "$(hostname -f) $placeholder $file" | md5sum | cut -c1-4) % (m-n) ) + n )) + fi + action_detail "... in above file, replacing $placeholder by $random" + sed_compat "s/$placeholder/$random/g" "$destfile" + done + fi + done + done + + for base in osh-encrypt-rsync.conf osh-backup-acl-keys.conf; do + if [ -f "$BASTION_ETC_DIR/$base" ]; then + chmod 0600 "$BASTION_ETC_DIR/$base" + fi + if [ -d "$BASTION_ETC_DIR/$base.d" ]; then + chmod 0700 "$BASTION_ETC_DIR/$base.d" + fi + done + + # ensure the includedir is present in global sudoers conf + action_doing "Add include directory in sudoers if needed" + if [ ! -e $SUDOERS_FILE ] ; then + action_error "$SUDOERS_DIR doesn't exist, is sudo installed?" + else + if grep -q "^#includedir $SUDOERS_DIR$" $SUDOERS_FILE ; then + action_na "sudoers.d already added in config" + else + echo '# added by the-bastion:' >> $SUDOERS_FILE + echo "#includedir $SUDOERS_DIR" >> $SUDOERS_FILE + action_done + fi + fi + + action_doing "Create sudoers.d if needed" + if [ ! -d "$SUDOERS_DIR" ]; then + mkdir -p "$SUDOERS_DIR" + action_done + else + action_na + fi + + # delete all bastion sudoers file (pattern osh-*) + action_doing "Remove obsolete sudoers files" + find "$SUDOERS_DIR/" -name "osh-*" -type f -delete + action_done + + # copy new sudoers files + action_doing "Copy sudoers files to $SUDOERS_DIR" + for file in "$basedir/etc/sudoers.d"/osh-*; do + action_detail "$file" + install -o "$UID0" -g "$GID0" -m 0440 "$file" "$SUDOERS_DIR/" + done + action_done + + # regenerate all group sudoers files + "$basedir/bin/sudogen/generate-sudoers.sh" group + + # regenerate all accounts sudoers files + "$basedir/bin/sudogen/generate-sudoers.sh" account + + # create the bastionsync account (needed for master/slave) + action_doing "Creating the bastionsync account" + if getent passwd bastionsync >/dev/null 2>&1; then + action_na + else + if ! groupadd_compat bastionsync 333; then + action_error "Error while adding bastionsync group" + elif ! useradd_compat bastionsync 333 '' "$basedir/bin/shell/bastion-sync-helper.sh" 333; then + action_error "Error while adding bastionsync user" + else + action_done + fi + fi + + # create some needed accounts + action_doing "Regenerating groups sudoers files from current template" + for i in keykeeper allowkeeper keyreader proxyhttp + do + action_doing "Create $i if needed" + if getent passwd $i >/dev/null ; then + action_na "this account already exists" + else + action_detail "account doesn't exist, creating" + useradd_compat "$i" "" "/home/$i" "/bin/false" + action_done + fi + done + chmod 0750 /home/keyreader /home/proxyhttp + chmod 0755 /home/allowkeeper /home/keykeeper + + chmod 0755 "$basedir"/bin/admin/fixrights.sh + "$basedir"/bin/admin/fixrights.sh + + # create passkeeper + action_doing "Create /home/passkeeper if needed" + if [ ! -d /home/passkeeper ] ; then + mkdir /home/passkeeper + action_done + else + action_na + fi + chmod 0755 /home/passkeeper + + # rename potential old groups to new names + action_doing "Rename legacy group to new names" + at_least_one_changed=0 + for i in accountListBastionKeys:accountListEgressKeys \ + selfAddPrivateAccess:selfAddPersonalAccess \ + selfDelPrivateAccess:selfDelPersonalAccess \ + accountAddPrivateAccess:accountAddPersonalAccess \ + accountDelPrivateAccess:accountDelPersonalAccess \ + accountListKeys:accountListIngressKeys \ + accountResetKeys:accountResetIngressKeys + do + old=osh-$(echo "$i" | cut -d: -f1) + new=osh-$(echo "$i" | cut -d: -f2) + if getent group "$old" >/dev/null ; then + at_least_one_changed=1 + # old group exists, does the new one exist too? + action_detail "Old group $old found" + if getent group "$new" >/dev/null ; then + # weird, both groups exist, just delete the old one + if groupdel "$old" ; then + action_detail "New group $new already existed, just deleted $old" + else + action_error "Error while attempting to delete $old" + fi + else + if group_rename_compat "$old" "$new"; then + action_detail "Renamed $old to $new" + else + action_error "Error while attempting to rename $old to $new" + fi + fi + fi + done + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # add groups for specific modules + action_doing "Create needed system groups" + at_least_one_changed=0 + for i in superowner admin auditor \ + $(find "$basedir/bin/plugin/restricted" -mindepth 1 -maxdepth 1 -type f -perm -u+x -print | sed -e "s=$basedir/bin/plugin/restricted/==") + do + i="osh-$i" + if getent group "$i" >/dev/null ; then + : + else + at_least_one_changed=1 + groupadd_compat "$i" + action_detail "$i created" + fi + done + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # fix bad authorized_keys2 contents created in some cases before v2.30.00 + action_doing "Fixing potential buggy keys in authorized_keys2 contents" + at_least_one_changed=0 + for account in $(getent passwd | grep ":$basedir/bin/shell/osh.pl$" | cut -d: -f1); do + test -f "/home/$account/.ssh/authorized_keys2" || continue + grep -Eq '^from="[^ ]+"(ssh-|ecdsa-)' "/home/$account/.ssh/authorized_keys2" || continue + at_least_one_changed=1 + action_detail "... $account" + sed_compat 's/^(from="[^ ]+")(ssh-|ecdsa-)/\1 \2/g' "/home/$account/.ssh/authorized_keys2" + done + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # lastoshuser + # ensures that users created without specifying IDs will be created + # with higher IDs than the lastoshuser UID + + # first, we check if it exists with the old name + action_doing "Checking if user lastoshuser exists with a deprecated name" + if getent passwd lastovhuser >/dev/null; then + # yes, ok, do we have also the new name? + if getent passwd lastoshuser >/dev/null; then + # yes, ok, just delete the old name + action_detail "... yes, but also exists with the new name, deleting the old one" + if userdel lastovhuser; then + action_done + else + action_error + fi + else + # no, ok, rename it + action_detail "... no, renaming it to the new name" + if usermod -l lastoshuser lastovhuser; then + action_done + else + action_error + fi + fi + else + action_na + fi + + # lastoshuser + # ... do the same for lastoshuser's main gid + + # first, we check if it exists with the old name + action_doing "Checking if group lastoshuser exists with a deprecated name" + if getent group lastovhuser >/dev/null; then + # yes, ok, do we have also the new name? + if getent group lastoshuser >/dev/null; then + # yes, ok, just delete the old name + action_detail "... yes, but also exists with the new name, deleting the old one" + if groupdel lastovhuser; then + action_done + else + action_error + fi + else + # no, ok, rename it + action_detail "... no, renaming it to the new name" + if group_rename_compat lastovhuser lastoshuser; then + action_done + else + action_error + fi + fi + else + action_na + fi + + # if user exists already, change its ID (old versions had uid 2999) + action_doing "Create user lastoshuser" + lastoshuseruid=$(getent passwd lastoshuser | cut -d: -f3) + if [ -n "$lastoshuseruid" ] && [ "$lastoshuseruid" != "10000" ] ; then + action_detail "user exists but with bad UID ($lastoshuseruid), fixing" + if usermod_changeuid_compat lastoshuser 10000; then + action_done + else + action_error "Error while attempting to change lastoshuser UID" + fi + elif [ -n "$lastoshuseruid" ] && [ "$lastoshuseruid" = "10000" ] ; then + action_na "user exists with proper UID" + else + action_detail "doesn't exist, creating it" + useradd_compat lastoshuser 10000 /nonexistent /bin/false + action_done + fi + + # ensure lastoshuser main group has a gid of 10000 + action_doing "Adjust gid of lastoshuser if needed" + lastoshgid=$(getent group lastoshuser | cut -d: -f3) + if [ -n "$lastoshgid" ] && [ "$lastoshgid" != "10000" ] ; then + action_detail "group exists but with bad GID ($lastoshgid), fixing" + if group_change_gid_compat lastoshuser 10000; then + action_done + else + action_error "Error while attempting to change lastoshuser GID" + fi + elif [ -n "$lastoshgid" ] && [ "$lastoshgid" = "10000" ] ; then + action_na "user exists with proper GID" + else + action_error "No group lastoshuser was found (!)" + fi + + # log + action_doing "Create /home/osh.log" + if [ ! -e /home/osh.log ] ; then + touch /home/osh.log + chmod a+w /home/osh.log + if command -v chattr &>/dev/null; then + chattr +a /home/osh.log 2>/dev/null || true + fi + action_done + else + action_na + fi + + action_doing "Create /var/log/bastion if needed" + if [ ! -d /var/log/bastion ] ; then + mkdir -p /var/log/bastion + action_done + else + action_na + fi + + action_doing "Set proper rights on /var/log/bastion" + chown "$UID0":allowkeeper /var/log/bastion + chmod 0710 /var/log/bastion + action_done + + # move old "always_active" flags to the new way + action_doing "Convert oldschool always_active flags if any" + at_least_one_changed=0 + while IFS= read -r -d '' i + do + at_least_one_changed=1 + account=$(echo "$i" | cut -d/ -f3 | cut -d. -f2) + if [ -z "$account" ] || ! [ -d "/home/$account" ] ; then + action_detail "unrecognized file, or account '$account' no longer existing, removing" + else + filename="/home/allowkeeper/$account/config.always_active" + echo yes > "$filename" + chmod 0644 "$filename" + chown allowkeeper:allowkeeper "$filename" + action_detail "converted $account" + fi + rm -v "$i" + done < <(find /home/ -mindepth 1 -maxdepth 1 -type f -name ".*.always_active" -print0) + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # migration auto: ensure all groups have their corresponding aclkeeper group + action_doing "Creating missing aclkeeper groups where needed" + at_least_one_changed=0 + for grp in $(getent group | cut -d: -f1 | grep -- '-gatekeeper$' | sed -e 's/-gatekeeper$//'); do + if ! getent group "$grp-aclkeeper" >/dev/null ; then + action_detail "... creating $grp-aclkeeper" + groupadd_compat "$grp-aclkeeper" HIGH + at_least_one_changed=1 + fi + done + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi + + # ensuring proper ACLs on group homes + action_doing "Ensuring proper ACLs on group homes and allowed.ip" + for grp in $(getent group | cut -d: -f1 | grep -- '-gatekeeper$' | sed -e 's/-gatekeeper$//'); do + test -f "/home/$grp/allowed.ip" || continue + if [ "$OS_FAMILY" = "Linux" ] || [ "$OS_FAMILY" = "FreeBSD" ]; then + setfacl -m "group:osh-whoHasAccessTo:--x" "/home/$grp" + setfacl -m "group:osh-auditor:--x" "/home/$grp" + setfacl -m "group:$grp-gatekeeper:--x" "/home/$grp" + setfacl -m "group:$grp-aclkeeper:--x" "/home/$grp" + setfacl -m "group:$grp-owner:--x" "/home/$grp" + fi + chgrp "$grp-aclkeeper" "/home/$grp/allowed.ip" + chmod 0664 "/home/$grp/allowed.ip" + done + action_done + + # ensuring proper ACLs on account homes + action_doing "Ensuring proper ACLs on account homes" + for accounthome in $(getent passwd | grep ":$basedir/bin/shell/osh.pl$" | cut -d: -f6); do + if test -d "$accounthome"; then + chmod 0750 "$accounthome" + setfacl -m g:osh-auditor:x "$accounthome" + fi + if test -d "$accounthome/.ssh"; then + setfacl -m g:osh-auditor:x "$accounthome/.ssh" + fi + done + action_done + + # v2.27.02+ (bastion-users), v2.30.00+ (mfa-*) + action_doing "Creating missing system groups where needed" + at_least_one_changed=0 + at_least_one_error=0 + for group in bastion-users \ + mfa-password-reqd mfa-password-bypass mfa-password-configd \ + mfa-totp-reqd mfa-totp-bypass mfa-totp-configd bastion-nopam + do + if getent group "$group" >/dev/null 2>&1; then + : + else + action_detail "... $group" + if groupadd_compat "$group"; then + at_least_one_changed=1 + else + at_least_one_error=1 + fi + fi + done + if [ "$at_least_one_error" != 0 ]; then + action_error + elif [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + # create logkeeper + action_doing "Create /home/logkeeper if needed" + if [ ! -d /home/logkeeper ] ; then + mkdir /home/logkeeper + action_done + else + action_na + fi + chown root:bastion-users /home/logkeeper + chmod 0730 /home/logkeeper + + # create the healthcheck account for the monitoring probe (doesn't harm even if not used) + action_doing "Creating the healthcheck account" + if getent passwd healthcheck >/dev/null 2>&1; then + action_na + else + ssh-keygen -q -t ed25519 -N '' -f "$UID0HOME/id_healthcheck" || \ + ssh-keygen -q -t ecdsa -N '' -f "$UID0HOME/id_healthcheck" || \ + ssh-keygen -q -t rsa -b 4096 -N '' -f "$UID0HOME/id_healthcheck" + if [ ! -e "$UID0HOME/id_healthcheck.pub" ]; then + action_error "Error while generating the SSH key" + else + chmod 0444 "$UID0HOME/id_healthcheck.pub" + USER="$UID0" HOME="$UID0HOME" "$basedir"/bin/plugin/restricted/accountCreate '' '' '' '' --account healthcheck --uid-auto --always-active --immutable-key --osh-only < "$UID0HOME/id_healthcheck.pub" + if ! getent passwd healthcheck >/dev/null 2>&1; then + action_error "Couldn't create the healthcheck account" + else + mv "$UID0HOME/id_healthcheck" ~healthcheck/.ssh/id_healthcheck + mv "$UID0HOME/id_healthcheck.pub" ~healthcheck/.ssh/id_healthcheck.pub + chown healthcheck:healthcheck ~healthcheck/.ssh/id_healthcheck ~healthcheck/.ssh/id_healthcheck.pub + chmod 400 ~healthcheck/.ssh/id_healthcheck + action_done + fi + fi + fi + + # ensure the system bastion accounts are in bastion-nopam group + at_least_one_changed=0 + at_least_one_error=0 + action_doing "Ensuring bastionsync and healthcheck are in bastion-nopam group" + for account in bastionsync healthcheck; do + if ! getent group bastion-nopam | grep -q -E "[:,]$account(,|$)"; then + if add_user_to_group_compat $account bastion-nopam; then + at_least_one_changed=1 + else + at_least_one_error=1 + fi + fi + done + if [ "$at_least_one_error" != 0 ]; then + action_error + elif [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + # create the master2slave ssh key (only needed for master, useless but not harmful for slaves) + action_doing "Generating the master/slave SSH key" + if [ -e "$UID0HOME/.ssh/id_master2slave" ]; then + action_na + else + ssh-keygen -q -t ed25519 -N '' -f "$UID0HOME/.ssh/id_master2slave" || \ + ssh-keygen -q -t ecdsa -N '' -f "$UID0HOME/.ssh/id_master2slave" || \ + ssh-keygen -q -t rsa -b 4096 -N '' -f "$UID0HOME/.ssh/id_master2slave" + if [ ! -e "$UID0HOME/.ssh/id_master2slave.pub" ]; then + action_error "Error while generating the SSH key" + else + action_done + fi + fi + + # create the ssh key for backups + action_doing "Generating the backup SSH key" + if [ -e "$UID0HOME/.ssh/id_backup" ]; then + action_na + else + ssh-keygen -q -t ed25519 -N '' -f "$UID0HOME/.ssh/id_backup" || \ + ssh-keygen -q -t ecdsa -N '' -f "$UID0HOME/.ssh/id_backup" || \ + ssh-keygen -q -t rsa -b 4096 -N '' -f "$UID0HOME/.ssh/id_backup" + if [ ! -e "$UID0HOME/.ssh/id_backup.pub" ]; then + action_error "Error while generating the SSH key" + else + action_done + fi + fi + + # grant the bastion admins to all the restricted commands, as we might have added new ones + action_doing "Granting all the restricted commands to the bastion admins" + admins=$(perl -I"$basedir/lib/perl" -MOVH::Bastion -e 'my $admins = OVH::Bastion::config("adminAccounts")->value; print join("\n", @{ $admins || [] })') + grant_status=0 + for account in $(getent group osh-admin | cut -d: -f4 | tr "," " ") + do + # we must also check that this account is declared in bastion.conf (or it's not really an admin), + if ! echo "$admins" | grep -qw "$account"; then + action_detail "$account is not really an admin, skipping" + continue + fi + action_detail "$account" + _before_change=$(id "$account") + set +e + "$basedir/bin/admin/grant-all-restricted-commands-to.sh" "$account" >/dev/null; ret=$? + set -e + _after_change=$(id "$account") + if [ "$ret" -ne 0 ]; then + action_detail "... failed!" + grant_status=-1 + elif [ "$_before_change" = "$_after_change" ]; then + action_detail "... nothing to do" + else + action_detail "... done" + [ "$grant_status" != -1 ] && grant_status=1 + fi + done + if [ "$grant_status" = 0 ]; then + action_na + elif [ "$grant_status" = -1 ]; then + action_error + else + action_done + fi + + action_doing "Ensuring all bastion accounts are in the bastion-users group" + bastion_users_members=$(getent group bastion-users | cut -d: -f4) + at_least_one_changed=0 + at_least_one_error=0 + for account in $(getent passwd | grep ":$basedir/bin/shell/osh.pl$" | cut -d: -f1) proxyhttp + do + if ! echo "$bastion_users_members" | tr "," "\\n" | grep -q -w "$account"; then + action_detail "... $account" + add_user_to_group_compat "$account" "bastion-users" || at_least_one_error=1 + at_least_one_changed=1 + fi + done + if [ "$at_least_one_error" != 0 ]; then + action_error + elif [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + action_doing "Archiving old account and group files" + at_least_one_changed=0 + at_least_one_error=0 + acls_param='' + [ "$OS_FAMILY" = "Linux" ] && acls_param='--acls' + [ "$OS_FAMILY" = "FreeBSD" ] && acls_param='--acls' + while IFS= read -r -d '' dir + do + at_least_one_changed=1 + if command -v chattr &>/dev/null; then + find "$dir" -name "*.log" -print0 | xargs -r0 chattr -a || true + fi + tarfile="$dir.tar.gz" + while [ -e "$tarfile" ]; do + # archive already exists? append some randomness to the name, we don't want to overwrite it! + tarfile="$dir.$(date +%s)-$RANDOM.tar.gz" + done + if tar czf "$tarfile" $acls_param --one-file-system -p --remove-files "$dir"; then + chmod 0 "$tarfile" + else + at_least_one_error=1 + fi + done < <(find /home/oldkeeper/accounts/ /home/oldkeeper/groups/ -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null) + if [ "$at_least_one_error" != 0 ]; then + action_error + elif [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + action_doing "Removing potentially bogus directories" + if [ -d /nonexistent ]; then + rmdir /nonexistent 2>/dev/null || true + action_done + else + action_na + fi + + action_doing "Replacing legacy o+w by bastion-users/g+w" + at_least_one_changed=0 + for file in $(test -f /home/osh.log && find /home/osh.log -perm -o+w) $(find /home/logkeeper/ -mindepth 1 -maxdepth 1 -type f -name "global-log-????.sqlite" -perm -o+w) + do + if [ "$file" = /home/osh.log ]; then + if command -v chattr &>/dev/null; then + chattr -a /home/osh.log 2>/dev/null || true + fi + fi + chown root:bastion-users "$file" + chmod 660 "$file" + if [ "$file" = /home/osh.log ]; then + if command -v chattr &>/dev/null; then + chattr +a /home/osh.log 2>/dev/null || true + fi + fi + at_least_one_changed=1 + done + if [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + action_doing "Ensuring symlinked plugins have their json config" + at_least_one_changed=0 + at_least_one_error=0 + while IFS= read -r -d '' plugin + do + if [ -e "${plugin}.json" ]; then + # the config is there, ok + continue + fi + # the config is not there, is there a corresponding .json at the other end of the symlink? + source=$(readlink "$plugin") + sourcecanon="$(dirname "$plugin")/$source" + sourcecanon="$(readlink -f "$sourcecanon")" + if [ -e "${sourcecanon}.json" ]; then + action_detail "... ${sourcecanon}.json found, symlinking" + ln -s "${source}.json" "${plugin}.json"; ret=$? + if [ $ret -ne 0 ]; then + at_least_one_error=1 + else + at_least_one_changed=1 + fi + fi + done < <(find "$basedir"/bin/plugin -type l ! -name "*.*" -print0) + if [ "$at_least_one_error" != 0 ]; then + action_error + elif [ "$at_least_one_changed" = 0 ]; then + action_na + else + action_done + fi + + # checking whether we have things to install from the install.d directory + # shellcheck disable=SC2034 + STARTED_BY_MAIN_INSTALL=1 + while IFS= read -r -d '' installfile + do + action_doing "Starting module install ($installfile)..." + # shellcheck disable=SC1090 + if . "$installfile"; then + action_done + else + action_error + fi + done < <(test -d "$basedir"/install/modules && find "$basedir"/install/modules -mindepth 2 -maxdepth 2 -name install -type f -print0) + unset STARTED_BY_MAIN_INSTALL +fi + +if [ "${opt[syslog-ng]}" = 1 ] && [ "${opt[overwrite-syslog-ng]}" = 1 ]; then + if [ -e /etc/syslog-ng/syslog-ng.conf ] && ! grep -q s_src /etc/syslog-ng/syslog-ng.conf && grep -q s_sys /etc/syslog-ng/syslog-ng.conf ; then + sed_compat 's/s_src/s_sys/g' /etc/syslog-ng/conf.d/20-bastion.conf + fi +fi + +if [ "$autodetect_startup_system" = 1 ]; then + action_doing "Autodetecting startup system..." + if command -v systemctl >/dev/null && [ -d "/var/run/systemd" ]; then + action_done "found systemd" + opt[systemd-units]=1 + opt[init]=0 + else + action_done "found sysV-style init" + opt[systemd-units]=0 + opt[init]=1 + fi +fi + +if [ "${opt[init]}" = 1 ]; then + initd=rc.d + [ "$OS_FAMILY" = Linux ] && initd=init.d + action_doing "Copy init scripts to /etc/$initd" + for file in "$basedir/etc/init.d"/osh-*; do + servicename=$(basename "$file") + if [ -e "/etc/$initd/$servicename" ]; then + isnew=0 + else + isnew=1 + fi + if install -o "$UID0" -g "$GID0" -m 0755 "$file" "/etc/$initd/"; then + if [ "$isnew" = 1 ]; then + action_detail "$servicename installed, ${WHITE_ON_BLUE}please use \`update-rc.d $servicename defaults' if you want to enable this service${NOC}" + else + action_detail "$servicename updated" + fi + else + action_error "failed installing $file" + fi + done + action_done + + # remove obsolete init.d files if needed + action_doing "Remove obsolete init.d files..." + at_least_one_changed=0 + # shellcheck disable=SC2043 + for obsolete in osh-proxy-http + do + if [ -e "/etc/$initd/$obsolete" ]; then + at_least_one_changed=1 + action_detail "removing $obsolete" + update-rc.d -f $obsolete disable || true + rm -f "/etc/$initd/$obsolete" + fi + done + if [ "$at_least_one_changed" = 1 ]; then + action_done + else + action_na + fi +fi + +if [ "${opt[systemd-units]}" = 1 ]; then + action_doing "Copy systemd unit files to /etc/systemd/system" + for file in "$basedir/etc/systemd"/osh-*.service; do + servicename="$(basename "$file")" + if [ -e "/etc/systemd/system/$servicename" ]; then + isnew=0 + else + isnew=1 + fi + if cp "$file" /etc/systemd/system/; then + if [ "$isnew" = 1 ]; then + action_detail "$servicename installed, ${WHITE_ON_BLUE}please use \`systemctl enable $servicename' if you want to enable this service${NOC}" + else + action_detail "$servicename updated" + fi + else + action_error "failed installing $file" + fi + done + systemctl daemon-reload || true + action_done +fi + +if [ "${opt[profile]}" = 1 ]; then + action_doing "Copy profile.d files if applicable" + if [ -d $ETC_DIR/profile.d ]; then + for file in "$basedir/etc/profile.d"/*.sh; do + action_detail "$file" + install -o "$UID0" -g "$GID0" -m 0755 "$file" "$ETC_DIR/profile.d/" + done + action_done + else + action_na "$ETC_DIR/profile.d not found" + fi +fi + +if [ "${opt[modify-umask]}" = 1 ]; then + # umask + action_doing "Adjust umask in /etc/login.defs if applicable" + if [ -e /etc/login.defs ]; then + sed_compat 's/^(\s*UMASK\s+).+/\1027/g' /etc/login.defs + action_done + else + action_na + fi + + action_doing "Adjust umask in $ETC_DIR/pam.d/common-session if applicable" + if [ -e $ETC_DIR/pam.d/common-session ]; then + if ! grep -Eq '^\s*session\s+optional\s+pam_umask.so\s+umask=0?027' \ + $ETC_DIR/pam.d/common-session ; then + action_detail "missing umask config in file, adjusting" + echo "# bastion config: umask needs to be at 0027" >> $ETC_DIR/pam.d/common-session + echo "session optional pam_umask.so umask=0027" >> $ETC_DIR/pam.d/common-session + action_done + else + action_na "umask was already OK" + fi + else + action_na "no file found" + fi +fi + +if [ "${opt[modify-pam-sshd]}" = 1 ]; then + action_doing "Use our template for pam.d/sshd" + if grep -Eiq '^[[:space:]]*AuthenticationMethods[[:space:]]+publickey,keyboard-interactive:pam' /etc/ssh/sshd_config; then + echo "$DISTRO_LIKE" | grep -q -w debian && pamsuffix=debian + echo "$DISTRO_LIKE" | grep -q -w rhel && pamsuffix=rhel + if [ -n "$pamsuffix" ] && [ -e $ETC_DIR/pam.d/sshd ] && [ -e "$basedir/etc/pam.d/sshd.$pamsuffix" ]; then + cp -a "$ETC_DIR/pam.d/sshd" "$ETC_DIR/pam.d/sshd.backup_$(date +%s)" + cat "$basedir/etc/pam.d/sshd.$pamsuffix" > $ETC_DIR/pam.d/sshd + action_done + else + action_error "couldn't use our pam.d/sshd template" + fi + else + action_na "the currently installed sshd_config file doesn't have a forced 'AuthenticationMethods publickey', we can't install our pam.d template safely (it could turn this machine into an allow-all accesses without auth through ssh!)" + fi +fi + +if [ "${opt[modify-pam-lastlog]}" = 1 ]; then + # pam.d lastlogin + action_doing "Adjust lastlog in pam.d/sshd if applicable" + if [ -e "$ETC_DIR/pam.d/sshd" ] ; then + if ! grep -Eq '^\s*session\s+optional\s+pam_lastlog.so' "$ETC_DIR/pam.d/sshd" ; then + action_detail "missing lastlog config in file, adjusting" + # shellcheck disable=SC1004 + sed_compat '/^\s*@include\s+common-session/a\ + # bastion config: lastlog needs to be updated on connection\nsession optional pam_lastlog.so silent' "$ETC_DIR/pam.d/sshd" + action_done + else + action_na "lastlog config was already ok" + fi + else + action_na "no file found" + fi +fi + +if [ "${opt[remove-weak-moduli]}" = 1 ]; then + # remove low moduli + action_doing "Remove weak moduli" + if [ -e $SSH_DIR/moduli ] ; then + tmpmod=$(mktemp) + awk '$5 >= 4095' $SSH_DIR/moduli > "$tmpmod" + if cmp -s $SSH_DIR/moduli "$tmpmod"; then + action_na "no weak moduli found" + else + cat "$tmpmod" > $SSH_DIR/moduli + action_done + fi + rm -f "$tmpmod" + fi +fi + +# optional migration: grant aclkeeper to gatekeepers +if [ "${opt[migration-grant-aclkeeper-to-gatekeepers]}" = 1 ] ; then + action_doing "Migration: giving the aclkeeper right to all gatekeepers" + for grp in $(getent group | cut -d: -f1 | grep -- '-gatekeeper$' | sed -e 's/-gatekeeper$//'); do + action_detail "... checking group $grp" + for gatek in $(getent group "$grp-gatekeeper" | cut -d: -f4 | tr "," "\\n"); do + action_detail "... $grp: granting $gatek as aclkeeper" + add_user_to_group_compat "$gatek" "$grp-aclkeeper"; ret=$? + if [ $ret -ne 0 ]; then + action_warn "Error while adding $gatek to $grp-aclkeeper!" + fi + done + done + action_done +fi + +# lastly, check for ttyrec version and yell if it's not the proper one +if [ "${opt[check-ttyrec]}" = 1 ] ; then + action_doing "Checking ttyrec version" + if ! command -v ttyrec >/dev/null 2>&1; then + action_error "ttyrec is not installed, the bastion will not work! Please either install ovh-ttyrec or run this script a second time with \`$0 --nothing --install-fake-ttyrec'" + else + ttyrec_version=$(ttyrec -V 2>/dev/null | grep -Eo 'ttyrec v[0-9.]+' | cut -c9-) + if [ -z "$ttyrec_version" ]; then + action_error "Incompatible ttyrec version installed, the bastion will not work! Please either install ovh-ttyrec or run this script again with \`$0 --nothing --install-fake-ttyrec'" + else + action_detail "found v$ttyrec_version" + action_detail "expected v$TTYREC_VERSION_NEEDED" + if [ "$(printf "%s\\n%s\\n" "$ttyrec_version" "$TTYREC_VERSION_NEEDED" | sort | head -1)" = "$TTYREC_VERSION_NEEDED" ]; then + action_done + else + action_error "The installed ttyrec version is too old, the bastion will not work! Please update ovh-ttyrec to at least v$TTYREC_VERSION_NEEDED" + fi + fi + fi +fi + +echo All done. diff --git a/bin/admin/osh-sync-watcher.sh b/bin/admin/osh-sync-watcher.sh new file mode 100755 index 0000000..f997868 --- /dev/null +++ b/bin/admin/osh-sync-watcher.sh @@ -0,0 +1,135 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +PIDFILE=/var/run/osh-sync-watcher.pid + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +configfile="$BASTION_ETC_DIR/osh-sync-watcher.sh" +if [ ! -e "$configfile" ] ; then + # to allow for smooth upgrades, look for the old file name if new is not found + configfile="$BASTION_ETC_DIR/sync-watcher.sh" + if [ ! -e "$configfile" ] ; then + echo "No configuration found, exiting" + exit 0 + fi +fi + +rsyncfilterfile="$BASTION_ETC_DIR/osh-sync-watcher.rsyncfilter" +if [ ! -e "$rsyncfilterfile" ] ; then + # to allow for smooth upgrades, look for the old file name if new is not found + rsyncfilterfile="$BASTION_ETC_DIR/sync-watcher-rsync.filter" + if [ ! -e "$rsyncfilterfile" ] ; then + echo "No rsync filter file found, exiting" + exit 0 + fi +fi + +# load configuration +# shellcheck source=etc/bastion/osh-sync-watcher.sh.dist +. "$configfile" + +# if a logdir is defined, tail to the log +# shellcheck disable=SC2154 +if [ -n "$logdir" ]; then + [ ! -d "$logdir" ] && mkdir -p "$logdir" + exec &>> >(tee -a "$logdir/osh-sync-watcher.log") +fi + +# if a syslog facility is defined, set the proper variable +# so that _log _warn and _err do log to syslog, +# also don't talk on stdout +if [ -n "$syslog" ]; then + LOG_FACILITY="$syslog" + LOG_QUIET=1 +fi + +if [ "$enabled" != "1" ] ; then + _log "Script is not enabled (review the config in $configfile if needed)" + exit 0 +fi + +# is another copy of myself still running ? +if [ -e "$PIDFILE" ] ; then + oldpid=$(head -1 "$PIDFILE") + if kill -0 -- "$oldpid" ; then + _log "Another copy of myself is running ($oldpid), exiting" + exit 0 + else + _log "Another copy of myself apparently died ($oldpid), cleaning up" + fi +fi +# shellcheck disable=SC2064 +trap "rm -f $PIDFILE" EXIT +rm -f "$PIDFILE" +# race condition here ... but /var/run is writable only by root +echo "$$" > "$PIDFILE" + +while : +do + _log "Watching for changes (timeout: $timeout)..." + # we'll cap to the max allowed + maxfiles=$(test -r /proc/sys/fs/inotify/max_user_watches && cat /proc/sys/fs/inotify/max_user_watches || echo 4096) + { + # account/group creation/deletion: + echo /etc/passwd + echo /etc/group + echo /home/allowkeeper + echo /home/keykeeper + echo /home/passkeeper + # all allowed.ip files of bastion groups: + for grouphome in $(getent group | grep -Eo '^key[a-zA-Z0-9_-]+' | grep -Ev -- '-(aclkeeper|gatekeeper|owner)$' | sed 's=^=/home/='); do + test -e "$grouphome/allowed.ip" && echo "$grouphome/allowed.ip" + done + # all authorized_keys files of bastion accounts: + for accountssh in $(getent passwd | grep ":$basedir/bin/shell/osh.pl\$" | cut -d: -f1 | sed 's=^=/home/=;s=$=/.ssh/='); do + find "$accountssh" -mindepth 1 -maxdepth 1 -name 'authorized_keys*' ! -name "*.backup*" -type f -print + done + } | head -"$maxfiles" | timeout "$timeout" inotifywait -e close_write -e moved_to -e create -e delete -e delete_self --quiet --recursive --csv --fromfile - ; ret=$? + if [ "$ret" = 124 ] ; then + _log "... timed out, syncing just in case!" + elif [ "$ret" = 0 ] ; then + _log "... got event, syncing in 3 secs!" + sleep 3 + else + _warn "... got weird return value $? (maxfiles=$maxfiles); sleeping a bit..." + sleep "$timeout" + fi + # sanity check myself before + if [ ! -d /home/allowkeeper ] || ! [ -d /home/keykeeper ] || ! [ -d /home/logkeeper ] || \ + [ "$(find /home -mindepth 2 -maxdepth 2 -type f -name lastlog 2>/dev/null | wc -l)" = 0 ] ; then + _log "Own sanity check failed (maybe I'm locked?), not syncing and sleeping" + sleep "$timeout" + continue + fi + # /sanity + _log "Starting sync!" + # shellcheck disable=SC2154 + [ -z "$remotehostlist" ] && remotehostlist="$remotehost" + for remote in $remotehostlist + do + if echo "$remote" | grep -q ':'; then + remoteport=$(echo "$remote" | cut -d: -f2) + remote=$(echo "$remote" | cut -d: -f1) + else + remoteport=22 + fi + if [ -e "$LOCKFILE" ] && [ $(( $(date +%s) - $(stat -c %Y "$LOCKFILE") )) -le 300 ]; then + _log "$remote: [1/3] syncing needed data postponed for next run (upgrade lockfile present)" + else + _log "$remote: [1/3] syncing needed data..." + rsync -vaA --numeric-ids --delete --filter "merge $rsyncfilterfile" --rsh "$rshcmd -p $remoteport" / "$remoteuser@$remote:/" + _log "$remote: [1/3] sync ended with return value $?" + fi + + _log "$remote: [2/3] syncing lastlog files from master to slave, only if master version is newer..." + rsync -vaA --numeric-ids --update --include '/' --include '/home/' --include '/home/*/' --include '/home/*/lastlog' --exclude='*' --rsh "$rshcmd -p $remoteport" / "$remoteuser@$remote:/" + _log "$remote: [2/3] sync ended with return value $?" + + _log "$remote: [3/3] syncing lastlog files from slave to master, only if slave version is newer..." + find /home -mindepth 2 -maxdepth 2 -type f -name lastlog | rsync -vaA --numeric-ids --update --prune-empty-dirs --include='/' --include='/home' --include='/home/*/' --include-from=- --exclude='*' --rsh "$rshcmd -p $remoteport" "$remoteuser@$remote:/" / + _log "$remote: [3/3] sync ended with return value $?" + done +done diff --git a/bin/admin/packages-check.sh b/bin/admin/packages-check.sh new file mode 100755 index 0000000..3fec629 --- /dev/null +++ b/bin/admin/packages-check.sh @@ -0,0 +1,146 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +opt_dev=0 +opt_install=0 +opt_syslogng=0 +opt_ttyrec=0 +opt_supervisor=0 +while builtin getopts "distv" opt; do + # shellcheck disable=SC2154 + case "$opt" in + "d") opt_dev=1;; + "i") opt_install=1;; + "s") opt_syslogng=1;; + "t") opt_ttyrec=1;; + "v") opt_supervisor=1;; + *) echo "Error $opt"; exit 1;; + esac +done + +action_doing "Detecting OS..." +action_detail "Found $OS_FAMILY" +if [ "$OS_FAMILY" = Linux ]; then + action_detail "Found distro $LINUX_DISTRO version $DISTRO_VERSION (major $DISTRO_VERSION_MAJOR), distro like $DISTRO_LIKE" +fi +action_done + +action_doing "Checking the list of installed packages..." +if echo "$DISTRO_LIKE" | grep -q -w debian; then + wanted_list="libcommon-sense-perl libjson-perl libnet-netmask-perl libnet-ip-perl \ + libnet-dns-perl libdbd-sqlite3-perl libterm-readkey-perl libdatetime-perl \ + fortunes-bofh-excuses sudo fping \ + xz-utils sqlite3 binutils acl libtimedate-perl libgnupg-perl gnupg rsync \ + libjson-xs-perl inotify-tools lsof curl libterm-readline-gnu-perl \ + libwww-perl libdigest-sha-perl libnet-ssleay-perl \ + libnet-server-perl cryptsetup mosh expect openssh-server locales \ + coreutils netcat bash libcgi-pm-perl iputils-ping" + [ "$opt_dev" = 1 ] && wanted_list="$wanted_list libperl-critic-perl perltidy" + if { [ "$LINUX_DISTRO" = debian ] && [ "$DISTRO_VERSION_MAJOR" -lt 9 ]; } || + { [ "$LINUX_DISTRO" = ubuntu ] && [ "$DISTRO_VERSION_MAJOR" -le 16 ]; }; then + wanted_list="$wanted_list openssh-blacklist openssh-blacklist-extra" + fi + if { [ "$LINUX_DISTRO" = debian ] && [ "$DISTRO_VERSION_MAJOR" -ge 8 ]; } || + { [ "$LINUX_DISTRO" = ubuntu ] && [ "$DISTRO_VERSION_MAJOR" -ge 14 ]; }; then + wanted_list="$wanted_list liblinux-prctl-perl libpam-google-authenticator pamtester" + fi + [ "$opt_syslogng" = 1 ] && wanted_list="$wanted_list syslog-ng syslog-ng-core" + [ "$opt_ttyrec" = 1 ] && wanted_list="$wanted_list ovh-ttyrec" + [ "$opt_supervisor" = 1 ] && wanted_list="$wanted_list supervisor" + + if [ "$opt_install" = 1 ]; then + export DEBIAN_FRONTEND=noninteractive + # shellcheck disable=SC2086 + apt-get update && apt-get install -y $wanted_list + exit $? + fi + + installed=$(dpkg -l | awk '/^ii/ {print $2}' | cut -d: -f1) + install_cmd="apt-get install" +elif echo "$DISTRO_LIKE" | grep -q -w rhel; then + wanted_list="perl-JSON perl-Net-Netmask perl-Net-IP \ + perl-Net-DNS perl-DBD-SQLite perl-TermReadKey perl-DateTime \ + sudo fping xz sqlite binutils acl perl-TimeDate gnupg rsync \ + perl-JSON-XS inotify-tools lsof curl perl-Term-ReadLine-Gnu \ + perl-libwww-perl perl-Digest perl-Net-Server cryptsetup mosh \ + expect openssh-server nc bash perl-CGI perl(Test::More) passwd \ + cracklib-dicts perl-Time-Piece perl-Time-HiRes which \ + perl-Sys-Syslog pamtester google-authenticator" + if [ "$DISTRO_VERSION_MAJOR" = 7 ]; then + wanted_list="$wanted_list fortune-mod coreutils" + fi + [ "$opt_syslogng" = 1 ] && wanted_list="$wanted_list syslog-ng" + [ "$opt_ttyrec" = 1 ] && wanted_list="$wanted_list ovh-ttyrec" + [ "$opt_supervisor" = 1 ] && wanted_list="$wanted_list supervisor" + + if [ "$opt_install" = 1 ]; then + if [ "$DISTRO_VERSION_MAJOR" = 8 ]; then + sed -i -e 's/enabled=.*/enabled=1/g' /etc/yum.repos.d/CentOS-PowerTools.repo + sed -i -e 's/enabled=.*/enabled=1/g' /etc/yum.repos.d/CentOS-Extras.repo + fi + yum install -y epel-release + # shellcheck disable=SC2086 + yum install -y $wanted_list + exit 0 + fi + + installed="FIXME" + install_cmd="yum install" +elif echo "$DISTRO_LIKE" | grep -q -w suse; then + wanted_list="perl-common-sense perl-JSON perl-Net-Netmask perl-Net-IP \ + perl-Net-DNS perl-DBD-SQLite perl-TermReadKey perl-DateTime \ + fortune sudo fping \ + xz sqlite binutils acl perl-TimeDate gnupg rsync \ + perl-JSON-XS inotify-tools lsof curl perl-TermReadLine-Gnu \ + perl-libwww-perl perl-Digest perl-IO-Socket-SSL \ + perl-Net-Server cryptsetup mosh expect openssh \ + coreutils netcat-openbsd bash perl-CGI iputils \ + perl-Time-HiRes which perl-Unix-Syslog hostname" + wanted_list="$wanted_list google-authenticator-libpam" + # perl-GnuPG + [ "$opt_syslogng" = 1 ] && wanted_list="$wanted_list syslog-ng" + [ "$opt_ttyrec" = 1 ] && wanted_list="$wanted_list ovh-ttyrec" + [ "$opt_supervisor" = 1 ] && wanted_list="$wanted_list python-supervisor python-setuptools" + + if [ "$opt_install" = 1 ]; then + if [ "$opt_supervisor" = 1 ]; then + zypper addrepo https://download.opensuse.org/repositories/home:bmanojlovic/openSUSE_Leap_15.0/home:bmanojlovic.repo + zypper refresh + fi + # shellcheck disable=SC2086 + zypper install -y $wanted_list + exit $? + fi + + installed="FIXME" + install_cmd="zypper install" +elif [ "$OS_FAMILY" = FreeBSD ]; then + if [ "$opt_install" = 1 ]; then + pkg install -y rsync bash sudo p5-JSON p5-JSON-XS p5-common-sense p5-Net-IP p5-GnuPG p5-DBD-SQLite p5-Net-Netmask p5-Term-ReadKey expect fping p5-Net-Server p5-CGI p5-LWP-Protocol-https + exit $? + fi +else + echo "This script doesn't support this OS yet ($DISTRO_LIKE)" >&2 + exit 1 +fi + +missing='' +for i in $wanted_list ; do + ok=0 + for j in $installed ; do + [ "$i" = "$j" ] && ok=1 && break + done + [ $ok = 1 ] || missing="$missing $i" +done + +if [ -n "$missing" ] ; then + action_error "Some packages are missing, to install them, use:" + action_detail "$install_cmd$missing" +else + action_done "All needed packages are installed" +fi diff --git a/bin/admin/rename-group.sh b/bin/admin/rename-group.sh new file mode 100755 index 0000000..b586ff2 --- /dev/null +++ b/bin/admin/rename-group.sh @@ -0,0 +1,110 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +from=$1 +to=$2 + +if [ -n "$3" ] || [ -z "$2" ] ; then + echo "Usage: $0 original_group_name new_group_name" + exit 2 +fi + +fail() +{ + echo "Error, will not proceed: $*" + exit 1 +} + +really_run_commands=0 + +_run() +{ + if [ "$really_run_commands" = "1" ] ; then + echo "Executing: $*" + read -r ___ + "$@" + else + echo "DRY RUN: would execute: $*" + fi +} + +batchrun() +{ +getent group "key$from" >/dev/null || fail "group key$from doesn't exist" +getent group "key$to" >/dev/null && fail "group key$to already exists" +_run groupmod -n "key$to" "key$from" + +getent passwd "key$from" >/dev/null || fail "user key$from doesn't exist" +getent passwd "key$to" >/dev/null && fail "user key$to already exists" +_run usermod -l "key$to" "key$from" + +if getent group "key$from-gatekeeper" >/dev/null ; then + # key$from-gatekeeper might not exist if the group name was too long from the beginning + getent group "key$to-gatekeeper" >/dev/null && fail "group key$to-gatekeeper already exists" + _run groupmod -n "key$to-gatekeeper" "key$from-gatekeeper" +fi + +if getent group "key$from-owner" >/dev/null ; then + # key$from-owner sometimes doesn't exist for old groups, so nevermind + getent group "key$to-owner" >/dev/null && fail "group key$to-owner already exists" + _run groupmod -n "key$to-owner" "key$from-owner" +fi + +test -d "/home/key$from" || fail "directory /home/key$from doesn't exists" +test -d "/home/key$to" && fail "directory /home/key$to already exists" +_run mv -v "/home/key$from" "/home/key$to" + +test -d "/home/keykeeper/key$from" || fail "directory /home/keykeeper/key$from doesn't exists" +test -d "/home/keykeeper/key$to" && fail "directory /home/keykeeper/key$to already exists" +_run mv -v "/home/keykeeper/key$from" "/home/keykeeper/key$to" + +if test -e "/etc/sudoers.d/osh-group-key$from" ; then + # if exists, will move it + test -e "/etc/sudoers.d/osh-group-key$to" && fail "file /etc/sudoers.d/osh-group-key$to already exists" + _run mv -v "/etc/sudoers.d/osh-group-key$from" "/etc/sudoers.d/osh-group-key$to" + _run sed -i -re "s/key$from/key$to/g" "/etc/sudoers.d/osh-group-key$to" +fi + +keykeeper="/home/keykeeper/key$from" +[ "$really_run_commands" = "1" ] && keykeeper="/home/keykeeper/key$to" +# shellcheck disable=SC2044 +for key in $(find "$keykeeper"/ -type f -name "id_*$from*" ! -name "*.pub") +do + test -e "$key" || continue + test -e "$key.pub" || fail "file $key.pub doesn't exist" + keyto=$(echo "$key" | sed -re "s/(id_.*)$from/\\1$to/") + test -e "$keyto" && fail "file $keyto already exists" + test -e "$keyto.pub" && fail "file $keyto.pub already exists" + _run mv -v "$key" "$keyto" + _run mv -v "$key.pub" "$keyto.pub" +done + +for account in /home/allowkeeper/*/ +do + fromfile="$account/allowed.partial.$from" + tofile="$account/allowed.partial.$to" + test -e "$fromfile" || continue + test -e "$tofile" && fail "file $tofile already exists" + _run mv -v "$fromfile" "$tofile" +done + +for account in /home/allowkeeper/*/ +do + fromfile="$account/allowed.ip.$from" + tofile="$account/allowed.ip.$to" + test -L "$fromfile" || continue + test -e "$tofile" && fail "file $tofile already exists" + _run rm -vf "$fromfile" + _run ln -vs "/home/key$to/allowed.ip" "$tofile" +done +} + + +really_run_commands=0 +batchrun +echo +echo "OK to proceed ? (CTRL+C to abort). You'll still have to validate each commands I'm going to run" +# shellcheck disable=SC2034 +read -r ___ +really_run_commands=1 +batchrun +echo "Done." diff --git a/bin/admin/restore-account.sh b/bin/admin/restore-account.sh new file mode 100755 index 0000000..cf4e50b --- /dev/null +++ b/bin/admin/restore-account.sh @@ -0,0 +1,61 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +account="$1" +backup_path="$2" + +if [ -z "$backup_path" ] || [ -n "$3" ]; then + echo "Restores a deleted account's data." + echo "The account must have been re-created first." + echo "WARNING: the newly created account information will be overwritten (keys, accesses)" + echo + echo "Usage: $0 " + echo "Example: $0 johndoe /home/oldkeeper/accounts/johndoe.at-1502153197.by-admin" + exit 1 +fi + +if ! getent passwd "$account" >/dev/null ; then + echo "Account '$account' doesn't seem to exist, you must re-create it first" + exit 2 +fi +homedir=$(getent passwd "$account" | cut -d: -f6) +if [ -z "$homedir" ] || ! [ -d "$homedir" ]; then + echo "Account '$account's homedir doesn't seem to exist ($homedir)" + exit 2 +fi + +if [ ! -d "$backup_path" ]; then + echo "Backup path '$backup_path' doesn't exist or is not a folder!" + exit 2 +fi + +if [ ! -d "$backup_path/allowkeeper" ] || ! [ -d "$backup_path/$account-home" ] ; then + echo "Backup path '$backup_path' doesn't seem to be a valid backup path!" + exit 2 +fi + +echo "Here is the contents of the allowkeeper dir of $account:" +find "/home/allowkeeper/$account/" +echo "Here is the contents of the current homedir of $account:" +find "$homedir/" +echo +echo -n "This will be replaced, does this look reasonable (y/n) ? " +read -r ans +if [ "$ans" != "y" ]; then + echo "Aborting." + exit 3 +fi + +chattr -a "$homedir"/*.log +mkdir "$homedir"/before-restore +chmod 0 "$homedir"/before-restore +find "$homedir" -mindepth 1 -maxdepth 1 ! -name before-restore -print0 | xargs -r0 mv -v -t "$homedir"/before-restore +rsync -vaP "$backup_path/$account-home/" "$homedir/" +chown -R "$account:$account" "$homedir/" +chattr +a "$homedir"/*.log +rsync -vaP --delete "$backup_path"/allowkeeper/ "/home/allowkeeper/$account/" + +echo "New allowkeeper info is as follows:" +ls -l "/home/allowkeeper/$account/" + +echo +echo "Done." diff --git a/bin/admin/setup-encryption.sh b/bin/admin/setup-encryption.sh new file mode 100755 index 0000000..dd9b132 --- /dev/null +++ b/bin/admin/setup-encryption.sh @@ -0,0 +1,205 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e +umask 077 + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +action_doing "Checking whether the proper tools are installed" +if ! command -v rsync >/dev/null || ! command -v cryptsetup >/dev/null; then + action_error "Missing rsync or cryptsetup, aborting" + exit 1 +else + action_done +fi + +action_doing "Checking whether /home is a separate partition" +home_block_device=$(awk '/ \/home / {print $1}' /proc/mounts) +if [ -n "$home_block_device" ] && [ -e "$home_block_device" ]; then + action_done "found $home_block_device" +else + action_error "No, aborting" + exit 1 +fi + +action_doing "Checking whether /home is in /etc/fstab" +if grep -qE '[[:space:]]/home[[:space:]]' /etc/fstab; then + action_done "$(grep '[[:space:]]/home[[:space:]]' /etc/fstab)" +else + action_error "No, aborting" + exit 1 +fi + +action_doing "Checking whether we can umount /home" +if umount /home; then + action_done +else + action_error "No, aborting" + exit 1 +fi + +action_doing "Checking whether we can remount /home" +if mount /home; then + action_done +else + action_error "No, aborting" + exit 1 +fi + +action_doing "Checking used space in /home" +home_used_mb=$(df -m /home | awk '{ print $3 }' | tail -n1) +if [ -n "$home_used_mb" ]; then + action_done "$home_used_mb MiB" +else + action_error "Couldn't get the /home used space" + exit 1 +fi + +action_doing "Checking available space in /" +slash_available_mb=$(df -m / | awk '{ print $4 }' | tail -n1) +if [ -n "$slash_available_mb" ]; then + action_done "$slash_available_mb MiB" +else + action_error "Couldn't get the / available space" + exit 1 +fi + +action_doing "Checking whether there is enough available space in / to hold /home contents temporarily" +if [ "$slash_available_mb" -gt "$home_used_mb" ]; then + action_done +else + action_error "Not enough free space in /" + exit 1 +fi + +action_doing "Creating temporary /tmphome" +# silently try to delete it just in case it exists but is empty +if [ -d /tmphome ]; then + rmdir /tmphome 2>/dev/null || true +fi +if [ -e /tmphome ]; then + action_error "/tmphome already exists! Aborting" + exit 1 +else + mkdir /tmphome + if [ ! -d /tmphome ]; then + action_error "Couldn't create /tmphome!" + exit 1 + else + action_done + fi +fi + +action_doing "Rsyncing /home to /tmphome" +if rsync -vaPHAX --exclude='lost+found' /home/ /tmphome/; then + action_done +else + action_error "Rsync failed, aborting!" + rm -Rf /tmphome + exit 1 +fi + +action_doing "Rsync done, here are some details:" +action_detail "ls /home : $(cd /home ; find . | tr '\n' ' ')" +action_detail "ls /tmphome: $(cd /tmphome ; find . | tr '\n' ' ')" +action_detail "du -shc /home : $(du -shc /home | grep total)" +action_detail "du -shc /tmphome: $(du -shc /tmphome | grep total)" +action_detail "" +action_detail "Does this look reasonable? [CTRL+C if not]" + +# shellcheck disable=SC2034 +read -r _dummy + +action_doing "Umounting /home" +if umount /home; then + action_done +else + action_error "Couldn't umount /home, aborting" + rm -Rf /tmphome + exit 1 +fi + +action_doing "Erasing /home block device and encrypting it (last chance to cancel!)" +action_detail "You should generate a strong password on your desk, with e.g. \`pwgen -s 10\`" +if cryptsetup luksFormat "$home_block_device"; then + action_done +else + action_error "Cryptsetup failed, aborting" + mount /home && rm -Rf /tmphome + exit 1 +fi + +action_doing "Opening newly encrypted block device" +if cryptsetup luksOpen "$home_block_device" home; then + action_done +else + action_error "Opening failed, aborting! Your /home partition is no longer valid, fix it manually! ($home_block_device)" + exit 1 +fi + +action_doing "Creating a new filesystem on top of the encrypted block device" +if mkfs.ext4 -T news -L home -M /home /dev/disk/by-id/dm-name-home; then + action_done +else + action_error "Filesystem creation failed, aborting! Your /home partition is no longer valid, fix it manually! ($home_block_device)" + exit 1 +fi + +action_doing "Setting up /etc/bastion/luks-config.sh with encrypted block device" +if sed -i -re "s;^DEV_ENCRYPTED=.*;DEV_ENCRYPTED=$home_block_device;" /etc/bastion/luks-config.sh; then + action_done +else + action_error "Couldn't modify /etc/bastion/luks-config.sh, please do it manually, continuing" +fi + +action_doing "Setting up /etc/fstab with encrypted block device" +newfstab=$(mktemp) +grep -Ev "[[:space:]]/home[[:space:]]" /etc/fstab > "$newfstab" +echo "# added by $(basename "$0") on $(date)" >> "$newfstab" +echo "/dev/disk/by-id/dm-name-home /home ext4 defaults,errors=remount-ro,noauto,nosuid,noexec,nodev 0 0" >> "$newfstab" +cat "$newfstab" > /etc/fstab +rm -f "$newfstab" +action_done + +action_doing "Remounting /home after encryption" +if mount /home; then + action_done +else + action_error "Error while remounting home, aborting!" + exit 1 +fi + +action_doing "Rsyncing back /home contents" +if rsync -vaPHAX --remove-source-files --exclude='lost+found' /tmphome/ /home/; then + action_done +else + action_error "Rsync failed, aborting!" + exit 1 +fi + +action_doing "Removing /tmphome" +if find /tmphome -depth -type d -empty -delete; then + action_done +else + action_error "Error while removing /tmphome, continuing anyway" +fi + +action_doing "Testing whether we can properly unlock /home after boot" +if umount /home; then + if cryptsetup luksClose home; then + if /opt/bastion/bin/admin/unlock-home.sh; then + action_done + else + action_error "Error with unlock-home.sh, ignoring" + fi + else + action_error "Couldn't luksClose home, ignoring" + fi +else + action_error "Couldn't umount /home to run the test, ignoring" +fi + +[ ! -e /root/unlock-home.sh ] && ln -s /opt/bastion/bin/admin/unlock-home.sh /root/ + diff --git a/bin/admin/setup-first-admin-account.sh b/bin/admin/setup-first-admin-account.sh new file mode 100755 index 0000000..aa6f198 --- /dev/null +++ b/bin/admin/setup-first-admin-account.sh @@ -0,0 +1,25 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +if [ -z "$2" ] || [ -n "$3" ]; then + echo "Usage: $0 " + echo "Note: UID can be the special value 'AUTO'" + exit 1 +fi + +if [ "$2" = AUTO ] || [ "$2" = auto ]; then + USER=root HOME=/root "$basedir/bin/plugin/restricted/accountCreate" '' '' '' '' --uid-auto --account "$1" +else + USER=root HOME=/root "$basedir/bin/plugin/restricted/accountCreate" '' '' '' '' --uid "$2" --account "$1" +fi + +"$basedir"/bin/admin/grant-all-restricted-commands-to.sh "$1" + +add_user_to_group_compat "$1" "osh-admin" + +sed_compat 's/^"adminAccounts": \[\]/"adminAccounts": ["'"$1"'"]/' "$BASTION_ETC_DIR/bastion.conf" diff --git a/bin/admin/setup-gpg.sh b/bin/admin/setup-gpg.sh new file mode 100755 index 0000000..e1c7360 --- /dev/null +++ b/bin/admin/setup-gpg.sh @@ -0,0 +1,141 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e +umask 077 + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +if command -v gpg1 >/dev/null 2>&1; then + gpgcmd="gpg1" +else + gpgcmd="gpg" +fi + +do_generate() +{ + key_size=4096 + rsync_conf="$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d/50-gpg-bastion-key.conf" + if [ -e "$rsync_conf" ]; then + echo "$rsync_conf already exists, aborting!" >&2 + exit 1 + fi + test -d "$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d" || mkdir "$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d" + + sign_key_pass=$(perl -e '$p .= chr(int(rand(93))+33) for (1..16); $p =~ s{["\\]}{~}g; print "$p"') + printf "Key-Type: RSA\\nKey-Length: $key_size\\nSubkey-Type: RSA\\nSubkey-Length: $key_size\\nName-Real: %s\\nName-Comment: Bastion signing key\\nName-Email: %s\\nExpire-Date: 0\\nPassphrase: %s\\n%%echo Generating GPG key, it'll take some time.\\n%%commit\\n%%echo done\\n" "$(hostname)" "root@$(hostname)" "$sign_key_pass" | $gpgcmd --gen-key --batch + + # get the id of the key we just generated + gpgid=$($gpgcmd --with-colons --list-keys "$(hostname) (Bastion signing key) " | awk -F: '/^pub:/ { print $5; exit; }') + + if [ -z "$gpgid" ]; then + echo "Error while generating key, couldn't find the ID in gpg --list-keys :(" >&2 + return 1 + fi + + cat > "$rsync_conf" <8--->8--->8--->8--->8--->8" + + echo + echo Done. +} + +do_import() +{ + rsync_conf="$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d/50-gpg-admins-key.conf" + if [ -e "$rsync_conf" ]; then + echo "$rsync_conf already exists, aborting!" >&2 + exit 1 + fi + test -d "$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d" || mkdir "$BASTION_ETC_DIR/osh-encrypt-rsync.conf.d" + backup_conf="$BASTION_ETC_DIR/osh-backup-acl-keys.conf.d/50-gpg.conf" + if [ -e "$backup_conf" ]; then + echo "$backup_conf already exists, aborting!" >&2 + exit 1 + fi + test -d "$BASTION_ETC_DIR/osh-backup-acl-keys.conf.d" || mkdir "$BASTION_ETC_DIR/osh-backup-acl-keys.conf.d" + + keys_before=$(mktemp) + # shellcheck disable=SC2064 + trap "rm -f $keys_before" EXIT INT + $gpgcmd --with-colons --list-keys | grep ^pub: | awk -F: '{print $5}' > "$keys_before" + echo "Paste the admins public GPG key:" + $gpgcmd --import + newkey='' + for key in $($gpgcmd --with-colons --list-keys | grep ^pub: | awk -F: '{print $5}'); do + grep -qw "$key" "$keys_before" || newkey="$key" + done + if [ -z "$newkey" ]; then + echo "Couldn't find which key you imported, aborting" >&2 + return 1 + fi + echo "Found generated key with ID: $newkey" + fpr=$($gpgcmd --with-colons --fingerprint --list-keys "$newkey" | awk -F: '/^fpr:/ {print $10 ; exit}') + if [ -z "$fpr" ]; then + echo "Couldn't find the fingerprint of the generated key $newkey, aborting" >&2 + return 1 + fi + echo "Found generated key fingerprint: $fpr" + echo "Trusting this key..." + $gpgcmd --import-ownertrust <<< "$fpr:6:" + + cat > "$rsync_conf" <8--->8--->8--->8--->8--->8" + + cat > "$backup_conf" <8--->8--->8--->8--->8--->8" + + echo + echo Done. + +} + +if [ "$1" = "--import" ]; then + do_import; exit $? +elif [ "$1" = "--generate" ]; then + do_generate; exit $? +fi + +echo "Usage: $0 <--import|--generate>" +echo +echo "Use --generate to generate a new GPG keypair for bastion signing" +echo "Use --import to import the administrator GPG key you've generated on your desk (ttyrecs, keys and acls backups will be encrypted to it)" +exit 0 diff --git a/bin/admin/unlock-home.sh b/bin/admin/unlock-home.sh new file mode 100755 index 0000000..02e46c7 --- /dev/null +++ b/bin/admin/unlock-home.sh @@ -0,0 +1,44 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +CONFIGFILE=/etc/bastion/luks-config.sh +# shellcheck source=etc/bastion/luks-config.sh.dist +. "$CONFIGFILE" + +do_mount() +{ + mount "$MOUNTPOINT"; ret=$? + if [ $ret -eq 0 ] ; then + echo "Success!" + else + echo "Failure... is $MOUNTPOINT correctly specified in /etc/fstab?" + fi + exit $ret +} + +if [ -z "$DEV_ENCRYPTED" ] || [ -z "$UNLOCKED_NAME" ] || [ -z "$MOUNTPOINT" ] || [ ! -d "$MOUNTPOINT" ] || [ ! -b "$DEV_ENCRYPTED" ] ; then + echo "Not configured or badly configured (check $CONFIGFILE), nothing to do." + exit 0 +fi + +if [ -e "$MOUNTPOINT/allowkeeper" ] ; then + echo "Already unlocked and mounted" + exit 0 +fi + +DEV_UNLOCKED="/dev/disk/by-id/dm-name-$UNLOCKED_NAME" +if [ -e "$DEV_UNLOCKED" ] ; then + echo "Already unlocked ($DEV_UNLOCKED), mounting..." + do_mount +fi + +echo "Mouting $DEV_ENCRYPTED as $UNLOCKED_NAME" +cryptsetup luksOpen "$DEV_ENCRYPTED" "$UNLOCKED_NAME" +sleep 1 +if [ -e "$DEV_UNLOCKED" ] ; then + echo "Mounting..." + do_mount +else + echo "Partition still encrypted, bad password?" + exit 1 +fi + diff --git a/bin/cron/osh-backup-acl-keys.sh b/bin/cron/osh-backup-acl-keys.sh new file mode 100755 index 0000000..69b7054 --- /dev/null +++ b/bin/cron/osh-backup-acl-keys.sh @@ -0,0 +1,125 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e +umask 077 + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +if command -v gpg1 >/dev/null 2>&1; then + gpgcmd="gpg1" +else + gpgcmd="gpg" +fi + +config_list='' +if [ -f "$BASTION_ETC_DIR/osh-backup-acl-keys.conf" ]; then + config_list="$BASTION_ETC_DIR/osh-backup-acl-keys.conf" +fi +if [ -d "$BASTION_ETC_DIR/osh-backup-acl-keys.conf.d" ]; then + config_list="$config_list $(find "$BASTION_ETC_DIR/osh-backup-acl-keys.conf.d" -mindepth 1 -maxdepth 1 -type f -name "*.conf" | sort)" +fi + +if [ -z "$config_list" ]; then + _err "No configuration loaded, aborting" + exit 1 +fi + +# load the config files only if they're owned by root:root and mode is o-rwx +for file in $config_list; do + if [ "$(find "$file" -uid 0 -gid 0 ! -perm /o+rwx | wc -l)" = 1 ] ; then + # shellcheck source=etc/bastion/osh-backup-acl-keys.conf.dist + . "$file" + else + _err "Configuration file not secure ($file), aborting." + exit 1 + fi +done + +# shellcheck disable=SC2153 +if [ -n "$LOGFILE" ] ; then + exec &>> >(tee -a "$LOGFILE") +fi + +if [ -z "$DESTDIR" ] ; then + _err "$0: Missing DESTDIR in configuration, aborting." + exit 1 +fi + +if ! echo "$DAYSTOKEEP" | grep -Eq '^[0-9]+$' ; then + _err "$0: Invalid specified DAYSTOKEEP value ($DAYSTOKEEP), aborting." + exit 1 +fi + +_log "Starting backup..." + +[ -d "$DESTDIR" ] || mkdir -p "$DESTDIR" + +tarfile="$DESTDIR/backup-$(date +'%Y-%m-%d').tar.gz" +_log "Creating $tarfile..." +supp_entries="" +for entry in /root/.gnupg /var/otp +do + [ -e "$entry" ] && supp_entries="$supp_entries $entry" +done +# SC2086: we don't want to quote $supp_entries, we want it expanded +# shellcheck disable=SC2086 +tar czf "$tarfile" -p --xattrs --acls --one-file-system --numeric-owner \ + --exclude=".encrypt" \ + --exclude="ttyrec" \ + --exclude="*.sqlite" \ + --exclude="*.log" \ + --exclude="*.ttyrec" \ + --exclude="*.gpg" \ + --exclude="*.gz" \ + --exclude="*.zst" \ + /home/ /etc/passwd /etc/group /etc/shadow /etc/gshadow /etc/bastion /etc/ssh $supp_entries 2>/dev/null; ret=$? +if [ $ret -eq 0 ]; then + _log "File created" +else + _err "Error while creating file (sysret=$ret)" +fi + +encryption_worked=0 +if [ -n "$GPGKEYS" ] ; then + cmdline="" + for recipient in $GPGKEYS + do + cmdline="$cmdline -r $recipient" + done + # just in case, encrypt all .tar.gz files we find in $DESTDIR + while IFS= read -r -d '' file + do + _log "Encrypting $file..." + rm -f "$file.gpg" # if the gpg file already exists, remove it + # shellcheck disable=SC2086 + if $gpgcmd --encrypt $cmdline "$file" ; then + encryption_worked=1 + shred -u "$file" 2>/dev/null || rm -f "$file" + else + _err "Encryption failed" + fi + done < <(find "$DESTDIR/" -mindepth 1 -maxdepth 1 -type f -name 'backup-????-??-??.tar.gz' -print0) +else + _warn "$tarfile will not be encrypted! (no GPGKEYS specified)" +fi + +# push to remote if needed +if [ -n "$PUSH_REMOTE" ] && [ "$encryption_worked" = 1 ] && [ -r "$tarfile.gpg" ] ; then + _log "Pushing backup file ($tarfile.gpg) remotely..." + # shellcheck disable=SC2086 + scp $PUSH_OPTIONS "$tarfile.gpg" "$PUSH_REMOTE"; ret=$? + if [ $ret -eq 0 ]; then + _log "Push done" + else + _err "Push failed (sysret=$ret)" + fi +fi + +# cleanup +_log "Cleaning up old backups..." +find "$DESTDIR/" -mindepth 1 -maxdepth 1 -type f -name 'backup-????-??-??.tar.gz' -mtime +"$DAYSTOKEEP" -delete +find "$DESTDIR/" -mindepth 1 -maxdepth 1 -type f -name 'backup-????-??-??.tar.gz.gpg' -mtime +"$DAYSTOKEEP" -delete +_log "Done" +exit 0 diff --git a/bin/cron/osh-compress-old-logs.sh b/bin/cron/osh-compress-old-logs.sh new file mode 100755 index 0000000..61ebc35 --- /dev/null +++ b/bin/cron/osh-compress-old-logs.sh @@ -0,0 +1,36 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +LOG_FACILITY=local6 + +_log "Compressing old sqlite databases..." + +while IFS= read -r -d '' sqlite +do + _log "Working on $sqlite..." + if ! gzip "$sqlite"; then + _log "Error while trying to compress $sqlite" + fi +done < <(find /home/ -mindepth 2 -maxdepth 2 -type f -name "*-log-??????.sqlite" -mtime +31 -print0) + +# also compress homedir logs that haven't been touched since 30 days, every day +while IFS= read -r -d '' log +do + _log "Working on $log..." + command -v chattr >/dev/null && chattr -a "$log" + if ! gzip "$log"; then + _log "Error while trying to compress $log" + fi +done < <(find /home/ -mindepth 2 -maxdepth 2 -type f -name "*-log-??????.log" -mtime +31 -print0) + +if command -v chattr >/dev/null; then + # then protect back all the logs + _log "Setting +a back on all the logs" + find /home/ -mindepth 2 -maxdepth 2 -type f -name "*-log-??????.log" -print0 | xargs -r0 chattr +a -- +fi + +_log "Done" diff --git a/bin/cron/osh-encrypt-rsync.pl b/bin/cron/osh-encrypt-rsync.pl new file mode 100755 index 0000000..314df97 --- /dev/null +++ b/bin/cron/osh-encrypt-rsync.pl @@ -0,0 +1,509 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use strict; +use warnings; + +use GnuPG; +use File::Temp; +use File::Basename; +use File::Find; +use File::Path; +use Getopt::Long; +use Fcntl qw{ :flock }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; + +use OVH::Bastion; +use OVH::SimpleLog; + +my %config; +my ($dryRun, $configTest, $forceRsync, $noDelete, $encryptOnly, $rsyncOnly, $verbose); +local $| = 1; + +my $isoldversion = ($GnuPG::VERSION ge '0.18') ? 0 : 1; + +sub test_config { + + # normalize / define defaults / quick checks + + $config{'trace'} = $config{'trace'} ? 1 : 0; + + if (not exists $config{'recipients'}) { + _err "config error: recipients must be defined"; + return 1; + } + if (ref $config{'recipients'} ne 'ARRAY') { + _err "config error: recipients must be an array of array of GPG key IDs! (layer 1)"; + return 1; + } + if (my @intruders = grep { ref $config{'recipients'}[$_] ne 'ARRAY' } 0 .. $#{$config{'recipients'}}) { + local $" = ', '; + _err "config error: recipients must be an array of array of GPG key IDs! (layer 2, indexes @intruders)"; + return 1; + } + + if ($config{'encrypt_and_move_delay_days'} !~ /^\d+$/) { + _err "config error: encrypt_and_move_delay_days is not a positive integer!"; + return 1; + } + + if ($config{'rsync_delay_before_remove_days'} !~ /^\d+$/) { + _err "config error: rsync_delay_before_remove_days is not a positive integer!"; + return 1; + } + + # ok, check if my gpg conf is good + my $input = File::Temp->new(UNLINK => 1, TMPDIR => 1); + print {$input} time(); + close($input); + + _log "Testing signature with key $config{signing_key}... "; + eval { + my $gpgtest = GnuPG->new(trace => $config{'trace'}); + my $outfile = File::Temp->new(UNLINK => 1, TMPDIR => 1); + + # first, check we can sign + $gpgtest->sign(plaintext => $input . "", output => $outfile . "", "local-user" => $config{signing_key}, passphrase => $config{signing_key_passphrase}); + if (not -s $outfile) { + die "Couldn't sign with the specified key $config{signing_key}, check your configuration"; + } + }; + if ($@) { + if ($@ =~ /BAD_PASSPHRASE/) { + _err "Bad passphrase for signing key $config{signing_key}"; + return 1; + } + elsif ($@ =~ /expected NEED_PASSPHRASE/) { + _err "Signing key $config{signing_key} was not found"; + return 1; + } + _err "When testing signing key: $@"; + return 1; + } + + my %recipients_uniq; + foreach my $recipient_list (@{$config{'recipients'}}) { + foreach my $recipient (@$recipient_list) { + $recipients_uniq{$recipient}++; + } + } + + eval { + foreach my $recipient (keys %recipients_uniq) { + _log "Testing encryption for recipient $recipient... "; + my $gpgtest = GnuPG->new(trace => $config{'trace'}); + + # then, check we can encrypt to each of the recipients + my $outfile = File::Temp->new(UNLINK => 1, TMPDIR => 1); + my $recipientparam = $isoldversion ? $recipient : [$recipient, $recipient]; + $gpgtest->encrypt(plaintext => $input . "", output => $outfile . "", recipient => $recipientparam); + if (not -s $outfile) { + die "Couldn't encrypt for the specified recipient <$recipient>, check your configuration"; + } + } + }; + if ($@) { + _err "When testing recipient key: $@"; + return 1; + } + + if ($isoldversion and keys %recipients_uniq > 1) { + _err "You have an old version of the GnuPG module that doesn't support multiple recipients, sorry."; + return 1; + } + + _log "Testing encryption for all recipients + signature... "; + eval { + my $gpgtest = GnuPG->new(trace => $config{'trace'}); + + # then, encrypt to all the recipients, sign, and check the signature + my $outfile = File::Temp->new(UNLINK => 1, TMPDIR => 1); + my $recipientparam = $isoldversion ? (keys %recipients_uniq)[0] : [keys %recipients_uniq]; + $gpgtest->encrypt( + plaintext => $input . "", + output => $outfile . "", + recipient => $recipientparam, + sign => 1, + "local-user" => $config{signing_key}, + passphrase => $config{signing_key_passphrase} + ); + if (not -s $outfile) { + die "Couldn't encrypt and sign, check your configuration"; + } + }; + if ($@) { + _err "When testing encrypt+sign: $@"; + return 1; + } + + _log "Config test passed"; + return 0; +} + +sub encrypt_multi { + my %params = @_; + my $source_file = $params{'source_file'}; + my $destination_directory = $params{'destination_directory'}; + my $remove_source_on_success = $params{'remove_source_on_success'} || 0; + + my $outfile = $source_file; + $outfile =~ s!^/home/!$destination_directory/!; + my $outdir = File::Basename::dirname($outfile); + + if (!-e $outdir) { + _log "Creating $outdir"; + $dryRun or File::Path::mkpath(File::Basename::dirname($outfile), 0, oct(700)); + } + + my $layers = scalar(@{$config{'recipients'}}); + _log "Encrypting $source_file to $outfile" . ".gpg" x $layers; + + my $layer = 0; + my $current_source_file = $source_file; + my $current_destination_file = $outfile . '.gpg'; + my $success = 1; + foreach my $recipients_array (@{$config{'recipients'}}) { + $layer++; + _log " ... encrypting $current_source_file to $current_destination_file" if $verbose; + my $error = encrypt_once( + source_file => $current_source_file, + destination_file => $current_destination_file, + recipients => ($isoldversion ? $recipients_array->[0] : $recipients_array) + ); + if ($layer > 1 and $layer <= $layers) { + + # transient file + _log " ... deleting transient file $current_source_file" if $verbose; + $dryRun or unlink $current_source_file; + } + if ($error) { + $success = 0; + last; + } + $current_source_file = $current_destination_file; + $current_destination_file .= '.gpg'; + } + if ($success and $remove_source_on_success) { + _log " ... removing source file $source_file" if $verbose; + $dryRun or unlink $source_file; + } + return !$success; +} + +sub encrypt_once { + my %params = @_; + my $source_file = $params{'source_file'}; + my $destination_file = $params{'destination_file'}; + my $recipients = $params{'recipients'}; + + if (not -f $source_file and not $dryRun) { + _err "encrypt_once: source file $source_file is not a file!"; + return 1; + } + + # don't care ... overwrite + # TODO check if GnuPG overwrites silently or dies + #if (-f $destination_file) + #{ + # _err "encrypt_once: destination file $destination_file already exists!"; + # return 1; + #} + + my $GPG = GnuPG->new(trace => $config{'trace'}); + eval { + $dryRun + or $GPG->encrypt( + plaintext => $source_file, + output => $destination_file, + recipient => $recipients, + sign => 1, + "local-user" => $config{signing_key}, + passphrase => $config{signing_key_passphrase} + ); + }; + if ($@) { + _err "encrypt_once: when working on $source_file => $destination_file, got error $@"; + return 1; + } + return 0; # no error +} + +my $openedFiles = undef; + +sub potentially_work_on_this_file { + + # file must be a ttyrec file or an osh_http_proxy_ttyrec-ish file + my $filetype; + $filetype = 'ttyrec' if m{^/home/[^/]+/ttyrec/[^/]+/[A-Za-z0-9._-]+(\.ttyrec(\.zst)?)?$}; + $filetype = 'proxylog' if m{^/home/[^/]+/ttyrec/[^/]+/\d+-\d+-\d+\.txt$}; + $filetype or return; + + # must exist and be a file + -f or return; + my $file = $_; + + # first, check if we populated $openedFiles as a hashref + if (ref $openedFiles ne 'HASH') { + $openedFiles = {}; + if (open(my $fh_lsof, '-|', "lsof -a -n -c ttyrec -- /home/")) { + while (<$fh_lsof>) { + chomp; + m{\s(/home/[^/]+/ttyrec/\S+)$} and $openedFiles->{$1} = 1; + } + close($fh_lsof); + _log "Found " . (scalar keys %$openedFiles) . " opened ttyrec files we won't touch"; + } + else { + _warn "Error trying to get the list of opened ttyrec files, we might rotate opened files!"; + } + } + + # still open ? don't touch + if (exists $openedFiles->{$file}) { + _log "File $file is still opened by ttyrec, skipping"; + return; + } + + # and must be older than encrypt_and_move_delay_days days + my $mtime = (stat($file))[9]; + if ($mtime > time() - 86400 * $config{'encrypt_and_move_delay_days'}) { + _log "File $file is too recent, skipping" if $verbose; + return; + } + + # for proxylog, never touch a file that's < 86400 sec old (because we might still write to it) + if ($filetype eq 'proxylog' and $mtime > time() - 86400) { + _log "File $file is too recent (proxylog), skipping" if $verbose; + return; + } + + my $error = encrypt_multi(source_file => $file, destination_directory => $config{'encrypt_and_move_to_directory'}, remove_source_on_success => not $noDelete); + if ($error) { + _err "Got an error for $file, skipping!"; + } + + return; +} + +sub directory_filter { ## no critic (RequireArgUnpacking) + + # /home ? check the subdirs + if ($File::Find::dir eq '/home') { + my @out = (); + foreach (@_) { + if (-d "/home/$_/ttyrec") { + + #_log("DBG: filtering /home, $_ is OK"); + push @out, $_ if -d "/home/$_/ttyrec"; + } + else { + ; #_log("DBG: filtering /home, $_ is COMPLETELY OUT"); + } + } + return @out; + } + if ($File::Find::dir =~ m{^/home/[^/]+($|/ttyrec)}) { + + #_log("DBG: yep ok $File::Find::dir"); + return @_; + } + + #_log("DBG: quickill $File::Find::dir"); + return (); +} + +sub main { + _log "Starting..."; + + if ( + not GetOptions( + "dry-run" => \$dryRun, + "config-test" => \$configTest, + "no-delete" => \$noDelete, + "encrypt-only" => \$encryptOnly, + "rsync-only" => \$rsyncOnly, + "force-rsync" => \$forceRsync, + "verbose" => \$verbose, + ) + ) + { + _err "Error while parsing command-line options"; + return 1; + } + + # we can have CONFIGDIR/osh-encrypt-rsync.conf + # but also CONFIGDIR/osh-encrypt-rsync.conf.d/* + # later files override the previous ones, item by item + + my $fnret; + my $lockfile; + my @configfilelist; + if (-f -r OVH::Bastion::main_configuration_directory() . "/osh-encrypt-rsync.conf") { + push @configfilelist, OVH::Bastion::main_configuration_directory() . "/osh-encrypt-rsync.conf"; + } + + if (-d -x OVH::Bastion::main_configuration_directory() . "/osh-encrypt-rsync.conf.d") { + if (opendir(my $dh, OVH::Bastion::main_configuration_directory() . "/osh-encrypt-rsync.conf.d")) { + my @subfiles = map { OVH::Bastion::main_configuration_directory() . "/osh-encrypt-rsync.conf.d/" . $_ } grep { /\.conf$/ } readdir($dh); + closedir($dh); + push @configfilelist, sort @subfiles; + } + } + + if (not @configfilelist) { + _err "Error, no config file found!"; + return 1; + } + + foreach my $configfile (@configfilelist) { + _log "Configuration: loading configfile $configfile..."; + $fnret = OVH::Bastion::load_configuration_file( + file => $configfile, + secure => 1, + ); + if (not $fnret) { + _err "Error while loading configuration from $configfile, aborting (" . $fnret->msg . ")"; + return 1; + } + foreach my $key (keys %{$fnret->value}) { + $config{$key} = $fnret->value->{$key}; + } + + # we'll be using our own config file as a handy flock() backend + $lockfile = $configfile if not defined $lockfile; + } + + $verbose ||= $config{'verbose'}; + + # ensure no other copy of myself is already running + # except if we are in rsync-only mode (concurrency is then not a problem) + my $lockfh; + if (not $rsyncOnly) { + if (!open($lockfh, '<', $lockfile)) { + + # flock() needs a file handler + _log "Couldn't open config file, aborting"; + return 1; + } + if (!flock($lockfh, LOCK_EX | LOCK_NB)) { + _log "Another instance is running, aborting this one!"; + return 1; + } + } + + # ensure the various config files defined all the keywords we need + foreach my $keyword ( + qw{ logfile signing_key signing_key_passphrase recipients encrypt_and_move_to_directory encrypt_and_move_delay_days rsync_destination rsync_delay_before_remove_days }) + { + next if defined $config{$keyword}; + _err "Missing mandatory configuration item '$keyword', aborting"; + return 1; + } + + OVH::SimpleLog::setLogFile($config{'logfile'}) if $config{'logfile'}; + OVH::SimpleLog::setSyslog($config{'syslog_facility'}) if $config{'syslog_facility'}; + + if ($forceRsync) { + config { 'rsync_delay_days' } = 0; + } + + if (test_config() != 0) { + _err "Config test failed, aborting"; + return 1; + } + + if ($configTest) { + return 0; + } + + if ($dryRun) { + _log "Dry-run mode enabled, won't actually encrypt, move or delete files!"; + } + + if (not $rsyncOnly) { + _log "Looking for files in /home/ ..."; + File::Find::find( + { + no_chdir => 1, + preprocess => \&directory_filter, + wanted => \&potentially_work_on_this_file + }, + "/home/", + ); + } + + if (not($encryptOnly || $config{'encrypt_only'}) and $config{'rsync_destination'}) { + my @command; + my $sysret; + + if (!-d $config{'encrypt_and_move_to_directory'} && $dryRun) { + _log "DRYRUN: source directory doesn't exist, substituting with another one (namely the config directory which we know exists), just to try the rsync in dry-run mode"; + $config{'encrypt_and_move_to_directory'} = '/etc/cron.d/'; + } + + if (!-d $config{'encrypt_and_move_to_directory'}) { + _log "Nothing to rsync as the rsync source dir doesn't exist"; + } + else { + _log "Now rsyncing files to remote host ..."; + @command = qw{ rsync --prune-empty-dirs --one-file-system -a }; + push @command, '-v' if $verbose; + if ($config{'rsync_rsh'}) { + push @command, '--rsh', $config{'rsync_rsh'}; + } + if ($dryRun) { + push @command, '--dry-run'; + } + + push @command, $config{'encrypt_and_move_to_directory'} . '/'; + push @command, $config{'rsync_destination'} . '/'; + _log "Launching the following command: @command"; + $sysret = system(@command); + + if ($sysret != 0) { + _err "Error while rsyncing, stopping here"; + return 1; + } + + # now run rsync again BUT only with files having mtime +rsync_delay_before_remove_days AND specifying --remove-source-files + # this way only files old enough AND successfully transferred to the other side will be removed + + if (!$dryRun) { + my $prevdir = $ENV{'PWD'}; + if (not chdir $config{'encrypt_and_move_to_directory'}) { + _err "Error while trying to chdir to " . $config{'encrypt_and_move_to_directory'} . ", aborting"; + return 1; + } + + _log "Building a list of rsynced files to potentially delete (older than " . $config{'rsync_delay_before_remove_days'} . " days)"; + my $cmdstr = "find . -xdev -type f -name '*.gpg' -mtime +" . ($config{'rsync_delay_before_remove_days'} - 1) . " -print0 | rsync -" . ($verbose ? 'v' : '') . "a "; + if ($config{'rsync_rsh'}) { + $cmdstr .= "--rsh '" . $config{'rsync_rsh'} . "' "; + } + if ($dryRun) { + $cmdstr .= "--dry-run "; + } + $cmdstr .= "--remove-source-files --files-from=- --from0 " . $config{'encrypt_and_move_to_directory'} . '/' . " " . $config{'rsync_destination'} . '/'; + _log "Launching the following command: $cmdstr"; + $sysret = system($cmdstr); + if ($sysret != 0) { + _err "Error while rsyncing for deletion, stopping here"; + return 1; + } + + # remove empty directories + _log "Removing now empty directories..."; + system("find " . $config{'encrypt_and_move_to_directory'} . " -type d ! -wholename " . $config{'encrypt_and_move_to_directory'} . " -delete 2>/dev/null") + ; # errors would be printed for non empty dirs, we don't care + + chdir $prevdir; + } + } + } + + _log "Done"; + return 0; +} + +exit main(); diff --git a/bin/cron/osh-lingering-sessions-reaper.sh b/bin/cron/osh-lingering-sessions-reaper.sh new file mode 100755 index 0000000..e95a46d --- /dev/null +++ b/bin/cron/osh-lingering-sessions-reaper.sh @@ -0,0 +1,47 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +LOG_FACILITY=local6 + +_log "Terminating lingering sessions..." + +tokill='' +nb=0 +# shellcheck disable=SC2162 +while read etimes pid tty +do + if [ "$tty" = "?" ] && [ "$etimes" -gt 86400 ]; then + tokill="$tokill $pid" + (( nb++ )) + fi +done < <(ps -C ttyrec -o etimes,pid,tty --no-header) +if [ -n "$tokill" ]; then + # shellcheck disable=SC2086 + kill $tokill + _log "Terminated $nb orphan ttyrec sessions (pids$tokill)" +fi + +tokill='' +nb=0 +# shellcheck disable=SC2162 +while read etimes pid tty user +do + if [ "$tty" = "?" ] && [ "$user" != "root" ] && [ "$etimes" -gt 86400 ]; then + if [ "$(ps --no-header --ppid "$pid" | wc -l)" = 0 ]; then + tokill="$tokill $pid" + (( nb++ )) + fi + fi +done < <(ps -C sshd --no-header -o etimes,pid,tty,user) +if [ -n "$tokill" ]; then + # shellcheck disable=SC2086 + kill $tokill + _log "Terminated $nb orphan sshd sessions (pids$tokill)" +fi + +_log "Done" diff --git a/bin/cron/osh-orphaned-homedir.sh b/bin/cron/osh-orphaned-homedir.sh new file mode 100755 index 0000000..9a3cf0f --- /dev/null +++ b/bin/cron/osh-orphaned-homedir.sh @@ -0,0 +1,52 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e +umask 077 + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +LOG_FACILITY=local6 + +_log "Checking orphaned home directories..." + +while IFS= read -r -d '' dir +do + test -d "/home/oldkeeper/orphaned" || mkdir -p "/home/oldkeeper/orphaned" + archive="/home/oldkeeper/orphaned/$(basename "$dir").at-$(date +%s).by-orphaned-homedir-script.tar.gz" + _log "Found orphaned $dir [$(ls -ld "$dir")], archiving..." + chmod 0700 /home/oldkeeper/orphaned + if [ "$OS_FAMILY" = "Linux" ]; then + find "$dir" -mindepth 1 -maxdepth 1 -type f -name "*.log" -print0 | xargs -r0 chattr -a + fi + # remove empty directories if we have some + find "$dir" -type d -delete 2>/dev/null || true + acls_param='' + [ "$OS_FAMILY" = "Linux" ] && acls_param='--acls' + [ "$OS_FAMILY" = "FreeBSD" ] && acls_param='--acls' + set +e + tar czf "$archive" $acls_param --one-file-system -p --remove-files --exclude=ttyrec "$dir" 2>/dev/null; ret=$? + set -e + if [ $ret -ne 0 ]; then + # $? can be 2 if we can't delete because ttyrec dir remains so it might not be a problem + if [ $ret -eq 2 ] && [ -s "$archive" ] && [ -d "$dir" ] && [ "$(find "$dir" -name ttyrec -prune -o -print | wc -l)" = 1 ]; then + # it's ok. we chown all to root to avoid orphan UID/GID and we let the backup script take care of those + # if we still have files under $dir/ttyrec, chown all them to root:root to avoid orphan UID/GID, + # and just wait for them to be encrypted/rsynced out of the bastion by the usual ttyrec archiving script + _log "Archived $dir to $archive" + chmod 0 "$archive" + + chown -R root:root "$dir" + _warn "Some files remain in $dir, we chowned everything to root" + else + _err "Couldn't archive $dir to $archive" + fi + else + _log "Archived $dir to $archive" + chmod 0 "$archive" + fi +done < <(find /home/ -mindepth 1 -maxdepth 1 -type d -nouser -nogroup -mmin +3 -print0) + +_log "Done" +exit 0 diff --git a/bin/cron/osh-piv-grace-reaper.pl b/bin/cron/osh-piv-grace-reaper.pl new file mode 100755 index 0000000..87a3dba --- /dev/null +++ b/bin/cron/osh-piv-grace-reaper.pl @@ -0,0 +1,105 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Result; +use OVH::SimpleLog; + +my $fnret; + +$fnret = OVH::Bastion::load_configuration_file( + file => OVH::Bastion::main_configuration_directory() . "/osh-piv-grace-reaper.conf", + secure => 1, + keywords => [qw{ SyslogFacility }], +); + +my $config; +if (not $fnret) { + _err "Error while loading configuration, continuing anyway with default values..."; +} +else { + $config = $fnret->value; + if (ref $config ne 'HASH') { + _err "Invalid data returned while loading configuration, continuing anyway with default values..."; + } +} + +# logging +if ($config && $config->{'SyslogFacility'}) { + OVH::SimpleLog::setSyslog($config->{'SyslogFacility'}); +} + +_log "Looking for accounts with a PIV grace..."; + +# loop through all the accounts, and only work on those that have a grace period set +$fnret = OVH::Bastion::get_account_list(); +if (!$fnret) { + _err "Couldn't get account list: " . $fnret->msg; + exit 1; +} + +# this'll be used in syslog +$ENV{'UNIQID'} = OVH::Bastion::generate_uniq_id()->value; + +foreach my $account (%{$fnret->value}) { + + # if account doesn't have PIV grace, don't bother + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE); + next if !$fnret; + + # we have PIV grace set for this account + my $expiry = $fnret->value; + my $human = OVH::Bastion::duration2human(seconds => ($expiry - time()))->value; + _log "Account $account has PIV grace expiry set to $expiry (" . $human->{'human'} . ")"; + + # is PIV grace TTL expired? + if (time() > $expiry) { + + # it is, but if current policy is not set to enforce, it's useless + _log "... grace for $account is expired, is current policy set to enforced?"; + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY); + if (!$fnret || $fnret->value ne 'yes') { + + # PIV grace expired but current policy is already relaxed, so just remove the grace flag + _log "... grace for $account is expired, but current policy is not set to enforced, removing grace..."; + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE, delete => 1); + if (!$fnret) { + _err "... couldn't remove grace flag for $account"; + next; + } + + # grace removed for this account, no change needed on keys because it wasn't set to enforced + next; + } + + # PIV grace expired, we need to remove the non-PIV keys from the account's authorized_keys2 file + _log "... grace for $account is expired, enforcing PIV-keys only..."; + OVH::SimpleLog::closeSyslog(); + $fnret = OVH::Bastion::ssh_ingress_keys_piv_apply(action => "enable", account => $account); + if (!$fnret) { + _err "... failed to re-enforce PIV policy for $account ($fnret->msg)"; + next; + } + if ($config && $config->{'SyslogFacility'}) { + OVH::SimpleLog::setSyslog($config->{'SyslogFacility'}); + } + _log "... re-enforced PIV policy for $account"; + + # ok, now remove grace flag + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE, delete => 1); + if (!$fnret) { + _err "... couldn't remove grace flag for $account"; + } + else { + _log "... grace flag removed for $account"; + } + } + else { + _log "... grace for $account is not expired yet, skipping..."; + } +} + +_log "Done"; diff --git a/bin/cron/osh-rotate-ttyrec.sh b/bin/cron/osh-rotate-ttyrec.sh new file mode 100755 index 0000000..6430edd --- /dev/null +++ b/bin/cron/osh-rotate-ttyrec.sh @@ -0,0 +1,36 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +LOG_FACILITY=local6 + +if [ "$1" = "--big-only" ]; then + _log "Rotating big ttyrec files..." + tokill='' + nb=0 + # shellcheck disable=SC2034 + while read -r command pid user fd type device size node name + do + if echo "$size" | grep -qE '^[0-9]+$' && [ "$size" -gt 100000000 ]; then + tokill="$tokill $pid" + (( nb++ )) + fi + done < <(lsof -a -n -c ttyrec 2>/dev/null -- /home/ 2>/dev/null) + if [ -n "$tokill" ]; then + _log "Rotating $nb big ttyrec files..." + # shellcheck disable=SC2086 + kill -USR1 $tokill + fi +else + _log "Rotating all ttyrec files..." + if pkill --signal USR1 ttyrec; then + _log "Rotation done" + else + _log "No ttyrec files to rotate" + fi +fi +_log "Done" diff --git a/bin/dev/debug_toggle.sh b/bin/dev/debug_toggle.sh new file mode 100755 index 0000000..cebe4b9 --- /dev/null +++ b/bin/dev/debug_toggle.sh @@ -0,0 +1,31 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +account=$1 +toggle=$2 + +_help() +{ + echo "$0 " + exit 1 +} + +[ -z "$toggle" ] && _help + +if [ ! -d "/home/$account" ] ; then + echo "/home/$account not found" + exit 1 +fi + +if [ "$toggle" = on ] ; then + echo yes > "/home/$account/config.debug" + chown "$account":"$account" "/home/$account/config.debug" + echo "debug enabled for $account" +elif [ "$toggle" = off ] ; then + rm -f "/home/$account/config.debug" + echo "debug disabled for $account" +else + echo "Unknown toggle ($toggle)" + _help +fi + +exit 0 diff --git a/bin/dev/perl-check.sh b/bin/dev/perl-check.sh new file mode 100755 index 0000000..ad863f1 --- /dev/null +++ b/bin/dev/perl-check.sh @@ -0,0 +1,48 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +# skip this script entirely if asked +[ "$1" = "--test-quick" ] && [ "$2" = 1 ] && exit 0 + +cmdline='-Mstrict -Mwarnings' +(( fails=0 )) +action_doing "Checking perl files syntax" +for i in $(find "$basedir"/bin -type f ! -name "*.orig") $(find "$basedir"/lib/perl -type f -name "*.pm") $(find "$basedir"/lib/perl -type f -name "*.inc") +do + i=$(readlink -f "$i") + if head -n1 "$i" | grep -Eq '/perl|/env perl' || head -n2 "$i" | grep -Eq '^package ' ; then + # FIXME remove below block when we get rid of GnuPG perl module in below script + if [ "$i" = "$basedir/bin/cron/osh-encrypt-rsync.pl" ] && echo "$DISTRO_LIKE" | grep -qw -e rhel -e suse; then + action_detail "${BLUE}$i${NOC}: skipping" + continue + fi + action_detail "${BLUE}$i${NOC}" + if grep -q -- 'perl -T' "$i"; then + # shellcheck disable=SC2086 + perl $cmdline -Tc "$i" 2>&1 | grep -v OK$ + else + # shellcheck disable=SC2086 + perl $cmdline -c "$i" 2>&1 | grep -v OK$ + fi + [ "${PIPESTATUS[0]}" -ne 0 ] && (( fails++ )) + [ -n "$DEBUG" ] || continue + grep -q '^use warnings' "$i" && echo "(spurious use warnings in $i)" + grep -q '^use strict' "$i" && echo "(spurious use strict in $i)" + grep -q '^use common::sense;' "$i" || echo "(missing common::sense in $i)" + fi +done +if [ -x "$basedir/bin/dev/perl-use-all.pl" ] ; then + action_detail "Trying to \`use' all required perl modules" + "$basedir/bin/dev/perl-use-all.pl" || (( fails++ )) +fi + +if [ "$fails" -ne 0 ] ; then + action_error "Got $fails errors" +else + action_done "success" +fi +exit "$fails" diff --git a/bin/dev/perl-critic.sh b/bin/dev/perl-critic.sh new file mode 100755 index 0000000..97b1c54 --- /dev/null +++ b/bin/dev/perl-critic.sh @@ -0,0 +1,19 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +cd "$basedir" || exit 1 + +action_doing "Checking perlcritic" +# shellcheck disable=SC2086 +perlcritic --color -q -p "$(dirname "$0")"/perlcriticrc .; ret=$? +if [ "$ret" = 0 ]; then + # shellcheck disable=SC2119 + action_done +else + action_error "perlcritic found errors" + exit 1 +fi diff --git a/bin/dev/perl-tidy.sh b/bin/dev/perl-tidy.sh new file mode 100755 index 0000000..586eb1f --- /dev/null +++ b/bin/dev/perl-tidy.sh @@ -0,0 +1,48 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +cd "$basedir" || exit 1 + +if [ "$1" = "test" ]; then + params="" + action_doing "Checking perl tidiness" +else + params="--backup-and-modify-in-place --backup-file-extension=/tidybak" + action_doing "Tidying perl files" +fi + +# shellcheck disable=SC2086 +find . -type f ! -name "*.tdy" ! -name "*.ERR" ! -name "$(basename $0)" -print0 | \ + xargs -r0 grep -l 'set filetype=perl' -- | \ + xargs -r perltidy --paren-tightness=2 --square-bracket-tightness=2 --brace-tightness=2 --maximum-line-length=180 $params + +bad="" +nbbad=0 + +if [ "$1" = "test" ]; then + while IFS= read -r -d '' tdy + do + file=${tdy/.tdy/} + if ! cmp "$file" "$tdy"; then + diff -u "$file" "$tdy" + bad="$bad $file" + nbbad=$(( nbbad + 1 )) + action_error "... $file is not tidy!" + fi + rm -f "$tdy" + done < <(find . -name "*.tdy" -type f -print0) + + if [ "$nbbad" = 0 ]; then + action_done "" + else + action_error "Found $nbbad untidy files" + fi +else + action_done "" +fi + +exit $nbbad diff --git a/bin/dev/perl-use-all.pl b/bin/dev/perl-use-all.pl new file mode 100755 index 0000000..a34f1b9 --- /dev/null +++ b/bin/dev/perl-use-all.pl @@ -0,0 +1,56 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: + +use strict; +use warnings; + +use Carp; +use CGI; +use common::sense; +use Config; +use Cwd; +use Data::Dumper; +use DBD::SQLite; +use Digest::MD5; +use Digest::SHA; +use Exporter; +use Fcntl; +use File::Basename; +use File::Copy; +use File::Find; +use File::Path; +use File::Temp; +use Getopt::Long; +use HTTP::Headers; +use HTTP::Message; +use HTTP::Request; +use IO::Compress::Gzip; +use IO::Handle; +use IO::Pipe; +use IO::Select; +use IO::Socket::SSL; +use IPC::Open2; +use IPC::Open3; +use JSON; +use List::Util; +use LWP::UserAgent; +use MIME::Base64; +use Net::IP; +use Net::Netmask; +use Net::Server::PreFork; +use Net::Server::PreForkSimple; +use POSIX; +use Scalar::Util; +use Socket; +use Storable; +use Symbol; +use Sys::Hostname; +use Sys::Syslog; +use Term::ANSIColor; +use Term::ReadKey; +use Term::ReadLine; +use Time::HiRes; +use Time::Piece; +use URI; + +print "OK: all required Perl modules are present\n"; diff --git a/bin/dev/perlcriticrc b/bin/dev/perlcriticrc new file mode 100644 index 0000000..501529d --- /dev/null +++ b/bin/dev/perlcriticrc @@ -0,0 +1,44 @@ +verbose = %f: [%p] %m at line %l, column %c.\n +severity = 2 + +[TestingAndDebugging::RequireUseStrict] +equivalent_modules = common::sense + +[TestingAndDebugging::RequireUseWarnings] +equivalent_modules = common::sense + +[Variables::RequireLocalizedPunctuationVars] +allow = %ENV %SIG $| + +[ValuesAndExpressions::RequireNumberSeparators] +min_value = 100000 + +[Subroutines::RequireFinalReturn] +terminal_funcs = HEXIT osh_exit osh_ok + +[ControlStructures::ProhibitDeepNests] +max_nests = 6 + +[-BuiltinFunctions::ProhibitBooleanGrep] +[-ControlStructures::ProhibitCascadingIfElse] +[-ControlStructures::ProhibitPostfixControls] +[-Documentation::RequirePodSections] +[-ErrorHandling::RequireCarping] +[-ErrorHandling::RequireCheckingReturnValueOfEval] +[-InputOutput::ProhibitExplicitStdin] +[-InputOutput::RequireBriefOpen] +[-InputOutput::RequireCheckedClose] +[-Modules::ProhibitExcessMainComplexity] +[-Modules::RequireFilenameMatchesPackage] +[-Modules::RequireVersionVar] +[-References::ProhibitDoubleSigils] +[-RegularExpressions::ProhibitComplexRegexes] +[-RegularExpressions::RequireDotMatchAnything] +[-RegularExpressions::RequireExtendedFormatting] +[-RegularExpressions::RequireLineBoundaryMatching] +[-Subroutines::ProhibitExcessComplexity] +[-ValuesAndExpressions::ProhibitConstantPragma] +[-ValuesAndExpressions::ProhibitEmptyQuotes] +[-ValuesAndExpressions::ProhibitMagicNumbers] +[-ValuesAndExpressions::ProhibitNoisyQuotes] +[-Variables::ProhibitPunctuationVars] diff --git a/bin/dev/shell-check.sh b/bin/dev/shell-check.sh new file mode 100755 index 0000000..96207c7 --- /dev/null +++ b/bin/dev/shell-check.sh @@ -0,0 +1,42 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +unset dockertag +if [ "$1" = "docker" ]; then + dockertag=v0.7.1 +fi +if [ -n "$2" ]; then + dockertag="$2" +fi + +(( fails=0 )) +if [ -n "$dockertag" ]; then + action_doing "Checking shell files syntax using shellcheck:$dockertag docker" +else + action_doing "Checking shell files syntax" +fi + +cd "$basedir" || exit 254 +for i in $(find . -type f ! -name "*.swp" -print0 | xargs -r0 grep -l 'set filetype=sh') +do + action_detail "${BLUE}$i${NOC}" + if [ -n "$dockertag" ]; then + docker run --rm -v "$PWD:/mnt" "koalaman/shellcheck:$dockertag" -Calways -W 0 -x -o deprecate-which,avoid-nullary-conditions,add-default-case "$i"; ret=$? + else + shellcheck -x "$i"; ret=$? + fi + if [ "$ret" != 0 ]; then + (( fails++ )) + fi +done + +if [ "$fails" -ne 0 ] ; then + action_error "Got $fails errors" +else + action_done "success" +fi +exit "$fails" diff --git a/bin/helper/osh-accountAddGroupServer b/bin/helper/osh-accountAddGroupServer new file mode 100755 index 0000000..5b100c4 --- /dev/null +++ b/bin/helper/osh-accountAddGroupServer @@ -0,0 +1,83 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as a gatekeeper, to be able to add the servers to /home/allowkeeper/ACCOUNT/allowed.partial.%GROUP% file +# KEYSUDOERS SUPEROWNERS, %%GROUP%-gatekeeper ALL=(allowkeeper) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-accountAddGroupServer --group %GROUP% * +# FILEMODE 0750 +# FILEOWN root allowkeeper + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $group, $ip, $user, $port, $action, $ttl, $forceKey); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "group=s" => sub { $group //= $_[1] }, + "ip=s" => sub { $ip //= $_[1] }, + "user=s" => sub { $user //= $_[1] }, + "port=i" => sub { $port //= $_[1] }, + "action=s" => sub { $action //= $_[1] }, + "ttl=i" => sub { $ttl //= $_[1] }, + "force-key=s" => sub { $forceKey //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $action or not $ip or not $account or not $group) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'action' or 'ip' or 'account' or 'group'"); +} + +#
PARAMS:ACTION +if (not grep { $action eq $_ } qw{ add del }) { + return R('ERR_INVALID_PARAMETER', msg => "expected 'add' or 'del' as an action"); +} + +#CODE +# access_modify validates all its parameters, don't do it ourselves here for clarity +$fnret = OVH::Bastion::access_modify( + way => 'groupguest', + account => $account, + group => $group, + action => $action, + user => $user, + ip => $ip, + port => $port, + ttl => $ttl, + forceKey => $forceKey +); +HEXIT($fnret); diff --git a/bin/helper/osh-accountCreate b/bin/helper/osh-accountCreate new file mode 100755 index 0000000..f3c24fc --- /dev/null +++ b/bin/helper/osh-accountCreate @@ -0,0 +1,436 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountCreate +# SUDOERS %osh-accountCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountCreate --type normal * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use Sys::Hostname (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($type, $account, $realmFrom, $uid, @pubKeys, $comment, $alwaysActive, $uidAuto, $oshOnly, $immutableKey, $ttl); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "type=s" => sub { $type //= $_[1] }, + "from=s" => sub { $realmFrom //= $_[1] }, + "uid=s" => sub { $uid //= $_[1] }, + "account=s" => sub { $account //= $_[1] }, + "always-active" => sub { $alwaysActive //= $_[1] }, + "pubKey=s" => \@pubKeys, + "comment=s" => sub { $comment //= $_[1] }, + 'uid-auto' => sub { $uidAuto //= $_[1] }, + 'osh-only' => sub { $oshOnly //= $_[1] }, + 'immutable-key' => sub { $immutableKey //= $_[1] }, + 'ttl=i' => sub { $ttl //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account || !$type) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'type'"); +} + +#
PARAMS:TYPE +osh_debug("Checking type"); +if (not grep { $type eq $_ } qw{ normal realm }) { + HEXIT('ERR_INVALID_PARAMETER', "Expected type 'normal' or 'realm'"); +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_account_valid(account => $account); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +$fnret = OVH::Bastion::is_account_existing(account => $account); +$fnret->is_err and HEXIT($fnret); +$fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The account $account already exists"); + +$fnret = OVH::Bastion::is_group_existing(group => $account); +$fnret->is_err and HEXIT($fnret); +$fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The group $account already exists"); + +if ($type eq 'realm') { + $account = "realm_$account"; + $fnret = OVH::Bastion::is_account_valid(account => $account, accountType => "realm"); + $fnret or HEXIT($fnret); + + $fnret = OVH::Bastion::is_account_existing(account => $account, accountType => "realm"); + $fnret->is_err and HEXIT($fnret); + $fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The realm $account already exists"); + + $fnret = OVH::Bastion::is_group_existing(group => $account); + $fnret->is_err and HEXIT($fnret); + $fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The group $account already exists"); +} + +#PARAMS:UID +if (not $uidAuto and not defined $uid) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing one of 'uid-auto' or 'uid' argument"); +} +if (defined $uid and $uidAuto) { + HEXIT('ERR_INCOMPATIBLE_PARAMETERS', msg => "Incompatible parameters 'uid' and 'uid-auto' specified"); +} +if (defined $uid) { + $fnret = OVH::Bastion::is_valid_uid(uid => $uid, type => 'user'); + $fnret or HEXIT($fnret); + $uid = $fnret->value; + getpwuid($uid) and HEXIT('ERR_UID_COLLISION', msg => "This UID ($uid) is already taken"); + + $fnret = OVH::Bastion::is_valid_uid(uid => $uid, type => 'group'); + $fnret or HEXIT($fnret); + getgrgid($uid) and HEXIT('ERR_GID_COLLISION', msg => "This GID ($uid) is already taken"); +} +elsif ($uidAuto) { + $fnret = OVH::Bastion::get_next_available_uid(); + $fnret or HEXIT($fnret); + $uid = $fnret->value(); +} + +#PARAMS +my $ttygroup = "$account-tty"; +$fnret = OVH::Bastion::is_group_existing(group => $ttygroup); +$fnret and HEXIT('ERR_TTY_GROUP_ALREADY_EXISTS', msg => "The TTY group for this account ($ttygroup) already exists!"); + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + if ($type eq 'realm') { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-realmCreate"); + $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } + else { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountCreate"); + $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#CODE +$fnret = OVH::Bastion::load_configuration(); +$fnret or HEXIT($fnret); +my $config = $fnret->value; + +my $ttygid = $uid + $config->{'ttyrecGroupIdOffset'}; +getgrgid($ttygid) and HEXIT('ERR_GID_COLLISION', msg => "This GID ($ttygid) is already taken"); + +if ($uid < $config->{'accountUidMin'} or $uid > $config->{'accountUidMax'}) { + HEXIT('ERR_UID_INVALID_RANGE', msg => "UID must be < " . $config->{'accountUidMin'} . " and > " . $config->{'accountUidMax'}); +} + +my @vettedKeys; +foreach my $key (@pubKeys) { + $fnret = OVH::Bastion::is_valid_public_key(pubKey => $key, way => 'ingress'); + $fnret or HEXIT($fnret); + + $key = $fnret->value->{'typecode'} . ' ' . $fnret->value->{'base64'}; + if ($fnret->value->{'comment'}) { + $key .= ' ' . $fnret->value->{'comment'}; + } + push @vettedKeys, $key; +} + +my $prefix = $fnret->value->{'prefix'}; +my @userProvidedIpList = (); +if ($prefix) { + my ($fromString) = $prefix =~ m{from=["']([^"']+)["']}; + if ($fromString) { + @userProvidedIpList = split /,/, $fromString; + } +} + +$fnret = OVH::Bastion::get_from_for_user_key(userProvidedIpList => \@userProvidedIpList); +$fnret or HEXIT($fnret); + +my $from = $fnret->value->{'from'}; +my $ipList = $fnret->value->{'ipList'}; +my $homedir = "/home/$account"; + +osh_info "Creating group $account with GID $uid..."; +$fnret = OVH::Bastion::sys_groupadd(noisy_stderr => 1, gid => $uid, group => $account); +$fnret->err eq 'OK' + or HEXIT('ERR_GROUPADD_FAILED', msg => "Error while running groupadd with UID $uid and group $account (" . $fnret->msg . ")"); +osh_debug('ok, group was created'); + +osh_info "Creating user $account with UID $uid..."; +$fnret = OVH::Bastion::sys_useradd( + noisy_stderr => 1, + user => $account, + uid => $uid, + gid => $uid, + shell => $OVH::Bastion::BASEPATH . '/bin/shell/osh.pl', + home => $homedir +); +$fnret->err eq 'OK' + or HEXIT('ERR_USERADD_FAILED', msg => "Error while running useradd for $account UID/GID $uid (" . $fnret->msg . ")"); +osh_debug('user created'); + +chmod 0750, $homedir; + +mkdir $homedir . "/.ssh" if (!-d "$homedir/.ssh"); +chmod 0750, $homedir . "/.ssh"; +chown $uid, $uid, "$homedir/.ssh"; + +if (!OVH::Bastion::touch_file("$homedir/.ssh/authorized_keys2")) { + HEXIT('ERR_CANNOT_CREATE_FILE', msg => "Failed to create authorized_keys file"); +} +chmod 0640, $homedir . "/.ssh/authorized_keys2"; +chown $uid, $uid, "$homedir/.ssh/authorized_keys2"; + +osh_info "Creating tty group of account..."; +$fnret = OVH::Bastion::sys_groupadd(noisy_stderr => 1, group => $ttygroup, gid => $ttygid); +$fnret->err eq 'OK' + or HEXIT('ERR_GROUPADD_FAILED', msg => "Error while running groupadd with UID $ttygid and group $ttygroup (" . $fnret->msg . ")"); +osh_debug('ok, group was created'); + +$fnret = OVH::Bastion::add_user_to_group(user => $account, group => $ttygroup, groupType => 'tty', accountType => ($type eq 'realm' ? 'realm' : 'normal')); +$fnret or HEXIT($fnret); + +# adding account to bastion-users group +$fnret = OVH::Bastion::add_user_to_group(user => $account, group => "bastion-users", accountType => ($type eq 'realm' ? 'realm' : 'normal')); +$fnret or HEXIT($fnret); + +if ($type ne 'realm') { + osh_info "Adding account to potential supplementary groups..."; + if ($config->{'accountCreateSupplementaryGroups'}) { + foreach my $suppGroup (@{$config->{'accountCreateSupplementaryGroups'}}) { + $fnret = OVH::Bastion::add_user_to_group(user => $account, group => $suppGroup, groupType => 'osh'); + if ($fnret) { + osh_info "Account added to group $suppGroup"; + } + else { + osh_warn "Couldn't add account $account to group $suppGroup"; + } + } + } +} + +osh_info "Creating needed files and directories with proper permissions in home..."; +my $ttyrecdir = $homedir . "/ttyrec"; +mkdir $ttyrecdir; +if (!chown $uid, $uid, $ttyrecdir) { + HEXIT('ERR_CANNOT_CHOWN', msg => "Couldn't chown ttyrec directory ($!)"); +} +if (!chmod 0700, $ttyrecdir) { + HEXIT('ERR_CANNOT_CHMOD', msg => "Couldn't chmod ttyrec directory ($!)"); +} + +osh_debug('applying an acl for group ' . $ttygroup); +OVH::Bastion::sys_setfacl(target => $ttyrecdir, clear => 1, perms => "g:$ttygroup:rX") + or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $ttyrecdir"); + +OVH::Bastion::sys_setfacl(target => $ttyrecdir, default => 1, perms => "g:$ttygroup:rX") + or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting default ACL on $ttyrecdir"); + +OVH::Bastion::sys_setfacl(target => $homedir, clear => 1, perms => "g:$ttygroup:x,g:osh-auditor:x") + or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $homedir"); + +OVH::Bastion::sys_setfacl(target => "$homedir/.ssh", clear => 1, perms => "g:osh-auditor:x") + or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACL on $homedir/.ssh"); + +osh_info "Creating some more directories..."; +mkdir "/home/allowkeeper/$account"; +OVH::Bastion::touch_file("/home/allowkeeper/$account/allowed.ip"); +OVH::Bastion::touch_file("/home/allowkeeper/$account/allowed.private"); + +osh_info "Applying proper ownerships..."; +$fnret = OVH::Bastion::execute( + cmd => ['chown', 'allowkeeper:allowkeeper', "/home/allowkeeper/$account", "/home/allowkeeper/$account/allowed.ip", "/home/allowkeeper/$account/allowed.private"], + noisy_stderr => 1 +); +$fnret->err eq 'OK' or HEXIT('ERR_CHMOD_FAILED', msg => "Error while running chmod on allowkeeper (" . $fnret->msg . ")"); + +$fnret = OVH::Bastion::execute(cmd => ['chmod', '-R', 'o+rX', "/home/allowkeeper/$account"], noisy_stderr => 1); +$fnret->err eq 'OK' or HEXIT('ERR_CHMOD_FAILED', msg => "Error while running chmod -R on allowkeeper (" . $fnret->msg . ")"); + +if (ref $config->{'accountCreateDefaultPersonalAccesses'} eq 'ARRAY' && $type eq 'normal') { + foreach my $defAccess (@{$config->{'accountCreateDefaultPersonalAccesses'}}) { + my (undef, $user, $ip, undef, $port) = $defAccess =~ m{^(([^@]+)@)?([0-9./]+)(:(\d+))?$}; + next unless $ip; + my @command = qw{ sudo -n -u allowkeeper -- }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess'; + push @command, '--target', 'any'; + push @command, '--action', 'add'; + push @command, '--account', $account; + push @command, '--ip', $ip; + if ($user) { + push @command, '--user', ($user eq 'ACCOUNT' ? $account : $user); + } + $port and push @command, '--port', $port; + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1, is_helper => 1); + $fnret->err eq 'OK' or osh_warn("Couldn't add private access to account to $defAccess (" . $fnret->msg . ")"); + } +} + +if (not defined $comment) { + $comment = '(no_comment_provided)'; +} + +$comment = "CREATED_BY=$self\nBASTION_VERSION=" . $OVH::Bastion::VERSION . "\nCREATION_TIME=" . localtime() . "\nCREATION_TIMESTAMP=" . time() . "\nCOMMENT=" . $comment . "\n"; + +if (open(my $fh_comment, '>>', $homedir . '/accountCreate.comment')) { + print $fh_comment $comment; + close $fh_comment; + chmod 0644, $homedir . '/accountCreate.comment'; +} + +$fnret = OVH::Bastion::account_config(account => $account, key => "creation_timestamp", value => time()); +if (!$fnret) { + osh_warn("Couldn't store creation timestamp (" . $fnret->msg . "), continuing anyway"); +} + +if ($ttl) { + $fnret = OVH::Bastion::duration2human(seconds => $ttl); + osh_info sprintf("Setting this account TTL (will expire in %s)", $fnret->value->{'human'}); + $fnret = OVH::Bastion::account_config(account => $account, key => "account_ttl", value => $ttl); + if (!$fnret) { + osh_warn("Couldn't store account TTL (" . $fnret->msg . "), this account will NOT expire!! Continuing anyway"); + } +} + +if ($alwaysActive || $type eq 'realm') { + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, value => "yes", public => 1); + if (!$fnret) { + osh_warn("Couldn't store always_active flag (" . $fnret->msg . "), continuing anyway"); + } +} + +$fnret = OVH::Bastion::add_user_to_group(user => "keyreader", group => $account, accountType => 'group'); +$fnret or HEXIT($fnret); +osh_debug('user keyreader added to group'); + +my $finalPrefix = $realmFrom ? sprintf('from="%s"', $realmFrom) : $from; +$finalPrefix .= ' ' if $finalPrefix; + +osh_info "Adding provided public key in authorized_keys..."; +my $allowedKeyFile = $homedir . '/.ssh/authorized_keys2'; +if (open(my $fh_keys, '>>', $allowedKeyFile)) { + foreach my $key (@vettedKeys) { + print $fh_keys $finalPrefix . $key . "\n"; + } + close($fh_keys); +} +else { + HEXIT("ERR_CANNOT_ADD_KEY", msg => "Couldn't open $allowedKeyFile when trying to add provided public key"); +} + +# push this flag to prevent ssh/telnet usage +if ($oshOnly) { + $fnret = OVH::Bastion::account_config(account => $account, key => "osh_only", value => "yes"); + $fnret or HEXIT($fnret); +} + +# chown to root so user can no longer touch it +if ($immutableKey) { + chown 0, -1, $allowedKeyFile; +} + +osh_info "Generating account personal bastion key..."; +$fnret = OVH::Bastion::generate_ssh_key( + folder => "$homedir/.ssh", + prefix => 'private', + name => $account, + gid => $uid, + uid => $uid, + algo => OVH::Bastion::config('defaultAccountEgressKeyAlgorithm')->value, + size => OVH::Bastion::config('defaultAccountEgressKeySize')->value, +); +$fnret or HEXIT($fnret); + +osh_info "Account successfully created!"; +if ($realmFrom) { + osh_info "Realm will be able to connect from the following IPs: $realmFrom"; +} +elsif (scalar(@$ipList) > 0) { + osh_info "Account will be able to connect from the following IPs: " . join(', ', @$ipList); +} + +# allowed to sudo for the account +osh_info("Configuring sudoers for this account"); +my $sudoers_dir = OVH::Bastion::sys_getsudoersfolder(); + +if (-e "$sudoers_dir/osh-account-$account") { + osh_debug "sudoers already in place, but overwriting it"; +} + +$fnret = OVH::Bastion::execute(cmd => [$OVH::Bastion::BASEPATH . '/bin/sudogen/generate-sudoers.sh', 'account', $account], must_succeed => 1, noisy_stdout => 1); +$fnret or HEXIT('ERR_CANNOT_CREATE_SUDOERS', msg => "An error occurred while creating sudoers for this account"); + +my $bastionName = $config->{'bastionName'}; +my $bastionCommand = $config->{'bastionCommand'}; +$bastionCommand =~ s/USER|ACCOUNT/$account/g; +$bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g; +my $hostname = Sys::Hostname::hostname(); +$bastionCommand =~ s/HOSTNAME/$hostname/g; + +if ($type eq 'realm') { + osh_info "Realm has been created."; +} +else { + osh_info "==> alias $bastionName='$bastionCommand'"; + osh_info "To test his access, ask this user to set the above alias in his .bash_aliases, then run `$bastionName --osh info'"; +} + +OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [ + ['action', 'create'], + ['account', $account], + ['uid', $uid], + ['public_key', @vettedKeys ? $vettedKeys[0] : undef], + ['always_active', ($alwaysActive ? 'true' : 'false')], + ['uid_auto', ($uidAuto ? 'true' : 'false')], + ['osh_only', ($oshOnly ? 'true' : 'false')], + ['immutable_key', ($immutableKey ? 'true' : 'false')], + ['comment', $comment], + ] +); + +HEXIT('OK'); diff --git a/bin/helper/osh-accountDelete b/bin/helper/osh-accountDelete new file mode 100755 index 0000000..2025814 --- /dev/null +++ b/bin/helper/osh-accountDelete @@ -0,0 +1,218 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountDelete +# SUDOERS %osh-accountDelete ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountDelete * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use File::Copy qw(move); + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$SIG{'HUP'} = 'IGNORE'; # continue even when attached terminal is closed (we're called with setsid on supported systems anyway) +$SIG{'PIPE'} = 'IGNORE'; # continue even if osh_info gets a SIGPIPE because there's no longer a terminal +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $type); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "type=s" => sub { $type //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account || !$type) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'type'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountDelete"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:TYPE +osh_debug("Checking type"); +if (not grep { $type eq $_ } qw{ normal realm }) { + HEXIT('ERR_INVALID_PARAMETER', "Expected type 'normal' or 'realm'"); +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => $type); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +#>CODE +# don't allow a non-admin deleting an admin +if (OVH::Bastion::is_admin(account => $account, sudo => 1) && !OVH::Bastion::is_admin(account => $self, sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You can't delete an admin without being admin yourself"); +} + +# Try to find the user -tty group +my $ttygroup = "$account-tty"; +if (!getgrnam($ttygroup)) { + $ttygroup = substr($account, 0, 5) . '-tty'; + if (!getgrnam($ttygroup)) { + osh_warn("Couldn't find out which is the tty group of this account, will still delete it anyway"); + $ttygroup = undef; + } +} + +# last security check +if ((getpwnam($account))[8] !~ m{/osh\.pl$}) { + HEXIT('ERR_INVALID_ACCOUNT', msg => "Account $account doesn't seem to be a legit bastion account"); +} + +# kill all user processes, if any +# GNU and BSD compliant +$fnret = OVH::Bastion::execute(cmd => ['ps', '-U', $account, '-o', 'pid'], noisy_stderr => 1); +if ($fnret->err ne 'OK' || ref $fnret->value->{'stdout'} ne 'ARRAY') { + ; # don't warn, we can get an return code of 1 just because there are no processes matching +} +else { + # don't check kill return because it may fail if the process died since, + # and it's not a big issue, we'll still delete the account + # we have to untaint what `ps` gave us however + my @pids; + foreach my $pid (@{$fnret->value->{'stdout'}}) { + push @pids, $1 if ($pid =~ m{(\d+)}); + } + kill 'KILL', @pids if @pids; +} + +# do the stuff +if (!-d "/home/oldkeeper") { + mkdir "/home/oldkeeper"; +} +chown 0, 0, "/home/oldkeeper"; +chmod 0700, "/home/oldkeeper"; + +if (!-d "/home/oldkeeper/accounts") { + mkdir "/home/oldkeeper/accounts"; +} +chown 0, 0, "/home/oldkeeper/accounts"; +chmod 0700, "/home/oldkeeper/accounts"; + +my $suffix = 'at-' . time() . '.by-' . $self; + +my $fulldir = "/home/oldkeeper/accounts/$account.$suffix"; +if (-e $fulldir) { + HEXIT('ERR_BACKUP_DIR_COLLISION', msg => "This shouldn't happen, $fulldir already exists!"); +} + +mkdir $fulldir; +chown 0, 0, $fulldir; +chmod 0700, $fulldir; + +move("/home/$account", "$fulldir/$account-home"); +move("/home/allowkeeper/$account", "$fulldir/allowkeeper"); + +# remove +a or tar won't be able to rm files, don't check if it succeeded if we're on a system without chattr +$fnret = OVH::Bastion::execute(cmd => ['find', "$fulldir/$account-home", '-maxdepth', '1', '-name', "*.log", '-exec', 'chattr', '-a', '{}', ';']); + +# remove sudoers if it's there +unlink(OVH::Bastion::sys_getsudoersfolder() . "/osh-account-$account"); + +# add a text file with all the groups the user was a member of +$fnret = OVH::Bastion::get_user_groups(account => $account, extra => 1); +if ($fnret) { + if (open(my $txtfile, '>', "$fulldir/groups.txt")) { + print $txtfile join("\n", @{$fnret->value}); + close($txtfile); + } + else { + osh_warn("Couldn't open the groups.txt file to save the group list of this account ($!)"); + } +} + +# now tar.gz the directory, this is important because inside we'll keep the +# old UID of the user, and we don't want UID-orphaned on our filesystem, it's +# not a problem to have those inside a tarfile however. +my @tarcmd = qw{ tar czf }; +push @tarcmd, $fulldir . '.tar.gz'; +push @tarcmd, '--acls' if OVH::Bastion::has_acls(); +push @tarcmd, '--one-file-system', '-p', '--remove-files', $fulldir; + +osh_info("Backing up home directory..."); +$fnret = OVH::Bastion::execute(cmd => \@tarcmd, must_succeed => 1); +if (!$fnret) { + osh_warn("Couldn't tar the backup homedir of this account (" . $fnret->msg . "), proceeding anyway."); + chmod 0000, $fulldir; +} +else { + chmod 0000, $fulldir . '.tar.gz'; + unlink($fulldir); +} +osh_info("Backup done"); + +osh_info "Removing '$account' group membership from 'keyreader' user"; +$fnret = OVH::Bastion::sys_delmemberfromgroup(user => "keyreader", group => $account); +$fnret or HEXIT($fnret); +osh_info "Deleting system user '$account'..."; +$fnret = OVH::Bastion::sys_userdel(user => $account); +$fnret or HEXIT($fnret); + +# some systems don't delete the primary group with userdel (suse at least) +$fnret = OVH::Bastion::execute(cmd => ['getent', 'group', $account]); +if ($fnret && $fnret->value->{'sysret'} == 0) { + osh_info "Deleting account main group '$account'..."; + $fnret = OVH::Bastion::sys_groupdel(group => $account); + $fnret or HEXIT($fnret); +} + +if (defined $ttygroup) { + osh_info "Deleting group $ttygroup..."; + $fnret = OVH::Bastion::sys_groupdel(group => $ttygroup); + $fnret or HEXIT($fnret); +} + +OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [['action', 'delete'], ['account', $account], ['tty_group', $ttygroup],] +); + +HEXIT('OK', value => {account => $account, ttygroup => $ttygroup, operation => 'deleted'}); diff --git a/bin/helper/osh-accountGeneratePassword b/bin/helper/osh-accountGeneratePassword new file mode 100755 index 0000000..13c8d73 --- /dev/null +++ b/bin/helper/osh-accountGeneratePassword @@ -0,0 +1,56 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# SUDOERS # to be able to generate an egress password for accounts +# SUDOERS %osh-accountGeneratePassword ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountGeneratePassword * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin::generatePassword; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my ($result, @optwarns); +my ($account, $size); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "size=i" => sub { $size //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $size or not $account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'size' or 'account'"); +} + +#
$self, context => 'account', account => $account, size => $size, sudo => 1)); diff --git a/bin/helper/osh-accountGetPasswordInfo b/bin/helper/osh-accountGetPasswordInfo new file mode 100755 index 0000000..1dc8436 --- /dev/null +++ b/bin/helper/osh-accountGetPasswordInfo @@ -0,0 +1,91 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-auditor +# SUDOERS %osh-auditor ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountGetPasswordInfo * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use Sys::Hostname (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $all); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "all" => sub { $all //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account && !$all) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'all'"); +} + +#
PARAMS:ACCOUNT +if ($account) { + osh_debug("Checking account"); + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or HEXIT($fnret); + + # get returned untainted value + $account = $fnret->value->{'account'}; +} + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-auditor"); + $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +#CODE +if ($account) { + HEXIT(OVH::Bastion::sys_getpasswordinfo(user => $account)); +} +$fnret = OVH::Bastion::get_account_list(); +$fnret or HEXIT($fnret); + +my %ret; +foreach my $acc (keys %{$fnret->value}) { + $ret{$acc} = OVH::Bastion::sys_getpasswordinfo(user => $acc)->value; + $ret{$acc}{'name'} = $acc; +} +HEXIT('OK', value => \%ret); diff --git a/bin/helper/osh-accountListEgressKeys b/bin/helper/osh-accountListEgressKeys new file mode 100755 index 0000000..52cd1af --- /dev/null +++ b/bin/helper/osh-accountListEgressKeys @@ -0,0 +1,74 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountListEgressKeys +# SUDOERS %osh-accountListEgressKeys ALL=(keyreader) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListEgressKeys * +# FILEMODE 0750 +# FILEOWN root keyreader + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] }); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountListEgressKeys"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +# $account)); diff --git a/bin/helper/osh-accountListIngressKeys b/bin/helper/osh-accountListIngressKeys new file mode 100755 index 0000000..49c5bcd --- /dev/null +++ b/bin/helper/osh-accountListIngressKeys @@ -0,0 +1,87 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountListIngressKeys +# SUDOERS %osh-accountListIngressKeys ALL=(keyreader) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListIngressKeys * +# FILEMODE 0750 +# FILEOWN root keyreader + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] }); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => ($account eq 'root' ? "osh-rootListIngressKeys" : "osh-accountListIngressKeys")); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +my $accounthome; +if ($account ne 'root') { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or HEXIT($fnret); + $account = $fnret->value->{'account'}; # untainted + $accounthome = $fnret->value->{'dir'}; +} +else { + $account = 'root'; + $accounthome = '/root'; +} + +# $file); + push @keys, @{$fnret->value} if ($fnret && $fnret->value); +} +HEXIT('OK', value => \@keys); diff --git a/bin/helper/osh-accountListPasswords b/bin/helper/osh-accountListPasswords new file mode 100755 index 0000000..97125d6 --- /dev/null +++ b/bin/helper/osh-accountListPasswords @@ -0,0 +1,74 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountListPasswords +# SUDOERS %osh-accountListPasswords ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListPasswords * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] }); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountListPasswords"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +# 'account', account => $account)); diff --git a/bin/helper/osh-accountMFAResetPassword b/bin/helper/osh-accountMFAResetPassword new file mode 100755 index 0000000..2afc439 --- /dev/null +++ b/bin/helper/osh-accountMFAResetPassword @@ -0,0 +1,94 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountMFAResetPassword +# SUDOERS %osh-accountMFAResetPassword ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountMFAResetPassword --account * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] },); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#>PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} + +# special case for self: if account==self, then is ok +elsif ($self ne $account) { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountMFAResetPassword"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +# $account, sudo => 1) && !OVH::Bastion::is_admin(account => $self, sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You can't reset the password of an admin without being admin yourself"); +} + +if (OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP)) { + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP); + $fnret or HEXIT($fnret); +} + +$fnret = OVH::Bastion::sys_neutralizepassword(user => $account); +$fnret or HEXIT($fnret); + +# remove expiration, or user could get locked out if s/he doesn't quickly set a new password, +# as the password expiration time is still taken into account even for '*' passwords +# 99999 is the /etc/shadow way to say "never" (273 years) +$fnret = OVH::Bastion::sys_setpasswordpolicy(user => $account, maxDays => 99999); +$fnret or HEXIT($fnret); + +osh_info "Password has been reset, " . ($account eq $self ? 'you' : $account) . " can setup a new password by using the `--osh selfMFASetupPassword' command, if applicable"; +HEXIT('OK'); diff --git a/bin/helper/osh-accountMFAResetTOTP b/bin/helper/osh-accountMFAResetTOTP new file mode 100755 index 0000000..af4a359 --- /dev/null +++ b/bin/helper/osh-accountMFAResetTOTP @@ -0,0 +1,91 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountMFAResetTOTP +# SUDOERS %osh-accountMFAResetTOTP ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountMFAResetTOTP --account * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] },); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#>PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted +my $home = $fnret->value->{'dir'}; + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} + +# special case for self: if account==self, then is ok +elsif ($self ne $account) { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountMFAResetTOTP"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +# $account, sudo => 1) && !OVH::Bastion::is_admin(account => $self, sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You can't reset the TOTP of an admin without being admin yourself"); +} + +if (OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP)) { + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP); + $fnret or HEXIT($fnret); +} + +# remove the .otp file (non-fatal) +if (!unlink($home . '/' . OVH::Bastion::TOTP_FILENAME)) { + osh_warn("Couldn't remove the TOTP file ($!), this is not fatal, continuing anyway"); +} + +osh_info "TOTP has been reset, " . ($account eq $self ? 'you' : $account) . " can re-enroll by using the `--osh selfMFASetupTOTP' command, if applicable"; +HEXIT('OK'); diff --git a/bin/helper/osh-accountModify b/bin/helper/osh-accountModify new file mode 100755 index 0000000..77fbd98 --- /dev/null +++ b/bin/helper/osh-accountModify @@ -0,0 +1,334 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountModify +# SUDOERS # modify parameters/policy of an account +# SUDOERS %osh-accountModify ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModify * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Result; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +Getopt::Long::Configure("no_auto_abbrev"); +my $fnret; +my ($result, @optwarns); +my ($account, @modify); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "modify=s" => \@modify, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account || !@modify) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'modify'"); +} + +#
PARAMS:ACCOUNT +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +$fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountModify"); +if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +if (OVH::Bastion::is_admin(account => $account, sudo => 1) && !OVH::Bastion::is_admin(account => $self, sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You can't modify the account of an admin without being admin yourself"); +} + +#CODE +my %result; + +# the TOTP and UNIX Password toggle codes are extremely similar, factorize it here +sub _mfa_toggle { + my ($key, $value, $mfaName, $mfaGroup, $mfaGroupBypass) = @_; + my $jsonkey = $key; + $jsonkey =~ s/-/_/g; + + # if the value is != bypass, remove the account from the bypass group + if ($value ne 'bypass') { + if (OVH::Bastion::is_user_in_group(user => $account, group => $mfaGroupBypass)) { + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => $mfaGroupBypass, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while removing the bypass option for this account"; + $result{$jsonkey} = R('ERR_REMOVING_FROM_GROUP'); + return; + } + } + } + + # if the value is == bypass, remove the account from the required group + elsif ($value eq 'bypass') { + if (OVH::Bastion::is_user_in_group(user => $account, group => $mfaGroup)) { + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => $mfaGroup, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while removing the required option for this account"; + $result{$jsonkey} = R('ERR_REMOVING_FROM_GROUP'); + return; + } + } + } + + $fnret = OVH::Bastion::is_user_in_group(user => $account, group => $mfaGroup); + if ($value eq 'yes') { + osh_info "Enforcing multi-factor authentication of type $mfaName for this account..."; + if ($fnret) { + osh_info "... no change was required"; + $result{$jsonkey} = R('OK_NO_CHANGE'); + return; + } + + $fnret = OVH::Bastion::sys_addmembertogroup(user => $account, group => $mfaGroup, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while setting the enforce option"; + $result{$jsonkey} = R('ERR_ADDING_TO_GROUP'); + return; + } + + osh_info "... done, this account is now required to setup a password with --osh selfMFASetup$mfaName on the next connection, before being allowed to do anything else"; + $result{$jsonkey} = R('OK'); + } + elsif ($value eq 'no') { + osh_info "Removing multi-factor authentication of type $mfaName requirement for this account..."; + if (!$fnret) { + osh_info "... no change was required"; + $result{$jsonkey} = R('OK_NO_CHANGE'); + return; + } + + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => $mfaGroup, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while setting the enforce option"; + $result{$jsonkey} = R('ERR_REMOVING_FROM_GROUP'); + return; + } + + osh_info +"... done, this account is no longer required to setup a password, however if there's already a password configured, it'll still be required (if this is not expected, the password can be reset with --osh accountMFAResetPassword command)"; + $result{$jsonkey} = R('OK'); + } + elsif ($value eq 'bypass') { + osh_info "Bypassing multi-factor authentication of type $mfaName requirement for this account..."; + $fnret = OVH::Bastion::is_user_in_group(user => $account, group => $mfaGroupBypass); + if ($fnret) { + osh_info "... no change was required"; + $result{$jsonkey} = R('OK_NO_CHANGE'); + return; + } + + $fnret = OVH::Bastion::sys_addmembertogroup(user => $account, group => $mfaGroupBypass, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while setting the enforce option"; + $result{$jsonkey} = R('ERR_ADDING_TO_GROUP'); + return; + } + + osh_info +"... done, this account will no longer have to setup a password, even if this is enforced by the default global policy.\nHowever if there's already a password configured, it'll still be required (if this is not expected, the password can be reset with --osh accountMFAResetPassword command)"; + $result{$jsonkey} = R('OK'); + } + return; +} + +sub _toggle_yes_no { + my %params = @_; + my $keyname = $params{'keyname'}; + my $keyfile = $params{'keyfile'}; + my $value = $params{'value'}; + + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => $keyfile); + if ($value eq 'yes') { + osh_info "Setting this account as $keyname..."; + if ($fnret) { + osh_info "... no change was required"; + return R('OK_NO_CHANGE'); + } + + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => $keyfile, value => 'yes'); + if (!$fnret) { + osh_warn "... error while setting the option"; + return R('ERR_OPTION_CHANGE_FAILED'); + } + + osh_info "... done, this account is now $keyname"; + return R('OK'); + } + elsif ($value eq 'no') { + osh_info "Removing the $keyname flag from this account..."; + if (!$fnret) { + osh_info "... no change was required"; + return R('OK_NO_CHANGE'); + } + + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => $keyfile, delete => 1); + if (!$fnret) { + osh_warn "... error while removing the option"; + return R('ERR_OPTION_CHANGE_FAILED'); + } + + osh_info "... done, this account has no longer the $keyname flag set"; + return R('OK'); + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Invalid value passed to $keyfile"); + } +} + +foreach my $tuple (@modify) { + my ($key, $value) = $tuple =~ /^([a-zA-Z0-9-]+)=([a-zA-Z0-9-]+)$/; + next if (!$key || !$value); + my $jsonkey = $key; + $jsonkey =~ s/-/_/g; + + osh_debug "working on tuple key=$key value=$value"; + if ($key eq 'always-active') { + $result{$jsonkey} = _toggle_yes_no(value => $value, keyfile => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, keyname => 'always-active'); + } + elsif ($key eq 'idle-ignore') { + $result{$jsonkey} = _toggle_yes_no(value => $value, keyfile => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, keyname => 'idle-ignore'); + } + elsif ($key eq 'pam-auth-bypass') { + $fnret = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP); + if ($value eq 'yes') { + { + osh_info "Bypassing sshd PAM auth usage for this account..."; + if ($fnret) { + osh_info "... no change was required"; + $result{$jsonkey} = R('OK_NO_CHANGE'); + last; + } + + $fnret = OVH::Bastion::sys_addmembertogroup(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while setting the bypass option"; + $result{$jsonkey} = R('ERR_ADDING_TO_GROUP'); + last; + } + + osh_info "... done, this account will no longer use PAM for authentication"; + $result{$jsonkey} = R('OK'); + } + } + elsif ($value eq 'no') { + { + osh_info "Removing bypass of sshd PAM auth usage for this account..."; + if (!$fnret) { + osh_info "... no change was required"; + $result{$jsonkey} = R('OK_NO_CHANGE'); + last; + } + + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP, noisy_stderr => 1); + if (!$fnret) { + osh_warn "... error while removing the bypass option"; + $result{$jsonkey} = R('ERR_REMOVING_FROM_GROUP'); + last; + } + + osh_info "... done, this account will no longer bypass PAM for authentication"; + $result{$jsonkey} = R('OK'); + } + } + } + elsif ($key eq 'mfa-password-required') { + _mfa_toggle($key, $value, 'Password', OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP, OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP); + } + elsif ($key eq 'mfa-totp-required') { + _mfa_toggle($key, $value, 'TOTP', OVH::Bastion::MFA_TOTP_REQUIRED_GROUP, OVH::Bastion::MFA_TOTP_BYPASS_GROUP); + } + elsif ($key eq 'egress-strict-host-key-checking') { + osh_info "Changing the egress StrictHostKeyChecking option for this account..."; + if (not grep { $value eq $_ } qw{ yes no ask default bypass }) { + osh_warn "Invalid parameter '$value', skipping"; + $result{$jsonkey} = R('ERR_INVALID_PARAMETER'); + } + else { + my $hostsFile; # undef, aka remove UserKnownHostsFile option + if ($value eq 'bypass') { + + # special case: for 'bypass', we set Strict to no and UserKnownHostsFile to /dev/null + $value = 'no'; + $hostsFile = '/dev/null'; + } + elsif ($value eq 'default') { + + # special case: for 'default', we actually remove the StrictHostKeyChecking option + undef $value; + } + $fnret = OVH::Bastion::account_ssh_config_set(account => $account, key => "StrictHostKeyChecking", value => $value); + $result{$jsonkey} = $fnret; + if ($fnret) { + $fnret = OVH::Bastion::account_ssh_config_set(account => $account, key => "UserKnownHostsFile", value => $hostsFile); + $result{$jsonkey} = $fnret; + } + if ($fnret) { + osh_info "... modification done"; + } + else { + osh_warn "... error while setting StrictHostKeyChecking policy: " . $fnret->msg; + } + } + } + elsif ($key eq 'personal-egress-mfa-required') { + osh_info "Changing the MFA policy for egress connections using the personal access (and keys) of the account..."; + if (not grep { $value eq $_ } qw{ password totp any none }) { + osh_warn "Invalid parameter '$value', skipping"; + $result{$jsonkey} = R('ERR_INVALID_PARAMETER'); + } + else { + $fnret = OVH::Bastion::account_config(account => $account, key => "personal_egress_mfa_required", value => $value); + $result{$jsonkey} = $fnret; + if ($fnret) { + osh_info "... modification done"; + } + else { + osh_warn "... error while setting MFA policy: " . $fnret->msg; + } + } + } +} + +HEXIT('OK', value => \%result); diff --git a/bin/helper/osh-accountModifyCommand b/bin/helper/osh-accountModifyCommand new file mode 100755 index 0000000..a68269b --- /dev/null +++ b/bin/helper/osh-accountModifyCommand @@ -0,0 +1,141 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountGrantCommand +# NEEDGROUP osh-accountRevokeCommand +# SUDOERS # grant access to a command +# SUDOERS %osh-accountGrantCommand ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyCommand --action grant * +# SUDOERS # revoke access to a command +# SUDOERS %osh-accountRevokeCommand ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyCommand --action revoke * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +Getopt::Long::Configure("no_auto_abbrev"); +my $fnret; +my ($result, @optwarns); +my ($action, $account, $command); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "action=s" => sub { $action //= $_[1] }, + "account=s" => sub { $account //= $_[1] }, + "command=s" => sub { $command //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $account or not $command or not $action) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account', 'command' or 'action'"); +} + +#
PARAMS:ACCOUNT +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +#PARAMS:ACTION +if ($action ne 'grant' && $action ne 'revoke') { + HEXIT('ERR_INVALID_PARAMETER', msg => "Parameter 'action' must be 'grant' or 'revoke'"); +} + +#PARAMS:COMMAND +if ($command =~ m{^([a-z0-9]+)$}i) { + $command = $1; # untaint +} +else { + HEXIT('ERR_INVALID_PARAMETER', msg => "Specified command is invalid ($command)"); +} + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +elsif ($action eq 'grant') { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountGrantCommand"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} +elsif ($action eq 'revoke') { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountRevokeCommand"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#CODE +$fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); +$fnret or HEXIT($fnret); +my @plugins = sort keys %{$fnret->value}; +push @plugins, 'auditor'; + +if (!grep { $command eq $_ } @plugins) { + HEXIT('ERR_INVALID_PARAMETER', msg => "Specified command ($command) is not in the restricted plugins list"); +} +if (grep { $command eq $_ } qw{ admin superowner }) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "Specified command ($command) can't be granted this way for security reasons"); +} +if (grep { $command eq $_ } qw{ accountGrantCommand accountRevokeCommand } && !OVH::Bastion::is_admin(sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "Specified command ($command) can only be granted by bastion admins for security reasons"); +} + +my $msg; +$fnret = OVH::Bastion::is_user_in_group(user => $account, group => "osh-$command"); +if ($action eq 'grant') { + HEXIT('OK_NO_CHANGE', msg => "Account $account already has the right to use the $command plugin, no change required") if $fnret; + + $fnret = OVH::Bastion::sys_addmembertogroup(user => $account, group => "osh-$command", noisy_stderr => 1); + $fnret or HEXIT($fnret); + + $msg = "Successfully granted use of restricted command $command to $account"; +} +elsif ($action eq 'revoke') { + HEXIT('OK_NO_CHANGE', msg => "Account $account did not have the right to use the $command plugin, no change required") if !$fnret; + + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $account, group => "osh-$command", noisy_stderr => 1); + $fnret or HEXIT($fnret); + + $msg = "Successfully revoked use of restricted command $command from $account"; +} + +HEXIT('OK', msg => $msg); diff --git a/bin/helper/osh-accountModifyPersonalAccess b/bin/helper/osh-accountModifyPersonalAccess new file mode 100755 index 0000000..b19d6be --- /dev/null +++ b/bin/helper/osh-accountModifyPersonalAccess @@ -0,0 +1,106 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-selfAddPersonalAccess +# SUDOERS %osh-selfAddPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target self --action add * +# FILEMODE 0750 +# FILEOWN root allowkeeper +# +# NEEDGROUP osh-accountAddPersonalAccess +# SUDOERS %osh-accountAddPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target any --action add * +# FILEMODE 0750 +# FILEOWN root allowkeeper +# +# NEEDGROUP osh-selfDelPersonalAccess +# SUDOERS %osh-selfDelPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target self --action del * +# FILEMODE 0750 +# FILEOWN root allowkeeper +# +# NEEDGROUP osh-accountDelPersonalAccess +# SUDOERS %osh-accountDelPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target any --action del * +# FILEMODE 0750 +# FILEOWN root allowkeeper + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $ip, $user, $port, $action, $ttl, $forceKey, $target, $comment); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "ip=s" => sub { $ip //= $_[1] }, + "user=s" => sub { $user //= $_[1] }, + "port=i" => sub { $port //= $_[1] }, + "action=s" => sub { $action //= $_[1] }, + "ttl=i" => sub { $ttl //= $_[1] }, + "force-key=s" => sub { $forceKey //= $_[1] }, + "target=s" => sub { $target //= $_[1] }, + "comment=s" => sub { $comment //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $action or not $ip or not $account or not $target) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'action' or 'ip' or 'account' or 'target'"); +} + +#
RIGHTSCHECK +if ($target eq 'self' && $self ne $account) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "Attempted to modify another account while you're only allowed to do it on yourself"); +} + +#PARAMS:ACTION +if (not grep { $action eq $_ } qw{ add del }) { + return R('ERR_INVALID_PARAMETER', msg => "expected 'add' or 'del' as an action"); +} + +#CODE +# access_modify validates all its parameters, don't do it ourselves here for clarity +$fnret = OVH::Bastion::access_modify( + way => 'personal', + account => $account, + action => $action, + user => $user, + ip => $ip, + port => $port, + ttl => $ttl, + forceKey => $forceKey, + comment => $comment, +); +HEXIT($fnret); diff --git a/bin/helper/osh-accountPIV b/bin/helper/osh-accountPIV new file mode 100755 index 0000000..20188f9 --- /dev/null +++ b/bin/helper/osh-accountPIV @@ -0,0 +1,187 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountPIV +# SUDOERS # modify PIV policy of an account +# SUDOERS %osh-accountPIV ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountPIV --step 1 --account * +# SUDOERS %osh-accountPIV ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountPIV --step 2 --account * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Result; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +Getopt::Long::Configure("no_auto_abbrev"); +my $fnret; +my ($result, @optwarns); +my ($account, $policy, $ttl, $step); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "policy=s" => sub { $policy //= $_[1] }, + "step=i" => sub { $step //= $_[1] }, + "ttl=i" => sub { $ttl //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account || !$policy || !$step) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'modify' or 'step'"); +} + +#
PARAMS:ACCOUNT +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +#PARAMS:POLICY +if (not grep { $policy eq $_ } qw{ none enforce grace }) { + HEXIT('ERR_INVALID_PARAMETER', "Expected either 'none,' enforce' of 'grace' as a parameter to --policy"); +} + +#PARAMS:TTL +if ($policy eq 'grace' && !defined $ttl) { + HEXIT('ERR_MISSING_PARAMETER', "The use of 'grace' requires to specify the --ttl parameter as well"); +} + +#PARAMS:STEP +if ($step ne '1' && $step ne '2') { + HEXIT('ERR_INVALID_PARAMETER', "Only 1 or 2 are allowed for --step"); +} + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +$fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountPIV"); +if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +if (OVH::Bastion::is_admin(account => $account, sudo => 1) && !OVH::Bastion::is_admin(account => $self, sudo => 1)) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You can't modify the account of an admin without being admin yourself"); +} + +#CODE +my $currentPolicy = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY, public => 1); +if ($step == 1) { + + # we're run under allowkeeper user, set config params where applicable + if ($policy eq 'enforce') { + if ($currentPolicy && $currentPolicy->value eq 'yes') { + HEXIT('OK_NO_CHANGE', msg => "PIV policy was already set to 'enforce' for this account, no change needed"); + } + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY, public => 1, value => 'yes'); + $fnret or HEXIT($fnret); + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE, public => 1, delete => 1); + + # ignore error because maybe grace wasn't set + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [['action', 'ingress-piv-policy'], ['account', $account], ['policy', 'enforce'],] + ); + HEXIT('OK', msg => "PIV policy set to 'enforce' for this account"); + } + elsif ($policy eq 'none') { + if (!$currentPolicy || $currentPolicy->value ne 'yes') { + HEXIT('OK_NO_CHANGE', msg => "PIV policy was already set to 'none' for this account, no change needed"); + } + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY, public => 1, delete => 1); + $fnret or HEXIT($fnret); + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE, public => 1, delete => 1); + + # ignore error because maybe grace wasn't set + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [['action', 'ingress-piv-policy'], ['account', $account], ['policy', 'none'],] + ); + HEXIT('OK', msg => "PIV policy set to 'none' for this account"); + } + elsif ($policy eq 'grace') { + if (!$currentPolicy || $currentPolicy->value ne 'yes') { + HEXIT('OK_NO_CHANGE', msg => "PIV policy is not set to 'enforce' for this account, so no need for a grace period"); + } + $fnret = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE, public => 1, value => (time() + $ttl)); + $fnret or HEXIT($fnret); + my $human = OVH::Bastion::duration2human(seconds => $ttl)->value; + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [['action', 'ingress-piv-policy'], ['account', $account], ['policy', 'grace'], ['duration', $human->{'duration'}],] + ); + HEXIT('OK', msg => "PIV policy is now in grace mode for " . $human->{'duration'} . " (until " . $human->{'date'} . ")"); + } + + # unreachable + HEXIT('ERR_INTERNAL', msg => "Unknown policy specified (step 1)"); +} +elsif ($step == 2) { + + # now we're running under the own account's user, modify the authkeys file accordingly + my $pivAction; + if ($policy eq 'enforce') { + $pivAction = 'enable'; + } + elsif ($policy eq 'none' || $policy eq 'grace') { + $pivAction = 'disable'; + } + else { + # unreachable + HEXIT('ERR_INTERNAL', msg => "Unknown policy specified (step 2)"); + } + + $fnret = OVH::Bastion::ssh_ingress_keys_piv_apply(action => $pivAction, account => $account); + $fnret or HEXIT($fnret); + if ($pivAction eq 'enable') { + HEXIT('OK', msg => "All non-PIV account's ingress keys have been disabled"); + } + else { + HEXIT('OK', msg => "Non-PIV account's ingress keys, if any, have been restored"); + } +} + +# unreachable +HEXIT('ERR_INTERNAL', msg => "Unknown step specified"); diff --git a/bin/helper/osh-accountUnexpire b/bin/helper/osh-accountUnexpire new file mode 100755 index 0000000..b15f611 --- /dev/null +++ b/bin/helper/osh-accountUnexpire @@ -0,0 +1,95 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-accountUnexpire +# SUDOERS %osh-accountUnexpire ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountUnexpire * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] }); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountUnexpire"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +# for this special helper, $account must be equal to $ENV{'USER'} +if (OVH::Bastion::get_user_from_env()->value ne $account) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this on $account, dear $self"); +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +#value->{'dir'}; +if (!-d $accounthome) { + HEXIT('ERR_INVALID_HOME', msg => "Invalid HOME directory for this account"); +} + +$fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $account); +$fnret->is_err and HEXIT($fnret); # couldn't read file or other error +$fnret->is_ok and HEXIT($fnret); # wasn't expired + +# is_ko: is expired +my $days = $fnret->value->{'days'}; +my $filepath = $fnret->value->{'filepath'}; + +$fnret = OVH::Bastion::touch_file($filepath); +$fnret or HEXIT($fnret); + +HEXIT('OK', value => {account => $account, days => $days}); diff --git a/bin/helper/osh-adminMaintenance b/bin/helper/osh-adminMaintenance new file mode 100755 index 0000000..fcfe278 --- /dev/null +++ b/bin/helper/osh-adminMaintenance @@ -0,0 +1,113 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-admin +# SUDOERS %osh-admin ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-adminMaintenance * +# FILEMODE 0750 +# FILEOWN root allowkeeper + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($action, $message); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "action=s" => sub { $action //= $_[1] }, + "message=s" => sub { $message //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $action) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'action'"); +} + +if (not grep { $action eq $_ } qw{ set unset }) { + HEXIT('ERR_INVALID_PARAMETER', msg => "Expected action 'set' or 'unset'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-admin"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#CODE + +my $retmsg; +if ($action eq 'set') { + if (-e '/home/allowkeeper/maintenance') { + HEXIT('OK_NO_CHANGE', msg => "Nothing to do, maintenance mode was already set"); + } + $fnret = OVH::Bastion::touch_file('/home/allowkeeper/maintenance', 0644); ## no critic (ProhibitLeadingZeros) + if (!$fnret) { + HEXIT('KO', msg => "Couldn't set the bastion to maintenance mode (" . $fnret->msg . ")"); + } + $message = "(no reason given)" if not $message; + $message .= " [set by $self at " . localtime(time()) . "]"; + if (open(my $fh, '>', '/home/allowkeeper/maintenance')) { + print $fh $message; + } + else { + osh_warn "Couldn't write the maintenance message ($!), but we're still setting the maintenance mode, users just won't see your maintenance message."; + } + $retmsg = "Maintenance mode is now enabled, new connections are disallowed (except for admins).\nGiven reason: $message"; +} +elsif ($action eq 'unset') { + if (-e '/home/allowkeeper/maintenance') { + if (!unlink('/home/allowkeeper/maintenance')) { + HEXIT('KO', msg => "Couldn't unset the bastion maintenance mode ($!)"); + } + } + else { + HEXIT('OK_NO_CHANGE', msg => "Nothing to do, maintenance mode was not set previously"); + } + $retmsg = "Maintenance mode is now disabled, new connections are allowed."; +} + +OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'maintenance', + fields => [['action', $action], ['message', $message],] +); + +# done at last! +HEXIT('OK', value => {action => $action, message => $message}, msg => $retmsg); diff --git a/bin/helper/osh-groupAddServer b/bin/helper/osh-groupAddServer new file mode 100755 index 0000000..8a363a5 --- /dev/null +++ b/bin/helper/osh-groupAddServer @@ -0,0 +1,114 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as an aclkeeper, we can add/del a server from the group server list in /home/%GROUP%/allowed.ip +# KEYSUDOERS SUPEROWNERS, %%GROUP%-aclkeeper ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupAddServer --group %GROUP% * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use Net::IP; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($group, $user, $ip, $port, $action, $force, $ttl, $comment); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "group=s" => sub { $group //= $_[1] }, # ignore subsequent --group on cmdline (anti-sudoers-override) + "user=s" => sub { $user //= $_[1] }, + "ip=s" => sub { $ip //= $_[1] }, + "port=i" => sub { $port //= $_[1] }, + "action=s" => sub { $action //= $_[1] }, + "force" => sub { $force //= $_[1] }, + "ttl=i" => sub { $ttl //= $_[1] }, + "comment=s" => sub { $comment //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $ip or not $group or not $action) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'ip' or 'group' or 'action'"); +} + +if (not grep { $action eq $_ } qw{ add del }) { + HEXIT('ERR_INVALID_PARAMETER', msg => "Argument action should be 'add' or 'del'"); +} + +#
PARAMS:GROUP +osh_debug("Checking group $group"); +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; +osh_debug("got group $group/$shortGroup"); + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + $fnret = OVH::Bastion::is_group_aclkeeper(account => $self, group => $shortGroup, sudo => 1, superowner => 1); + $fnret or HEXIT('ERR_NOT_ALLOWED', msg => "Sorry, you must be an aclkeeper of group $shortGroup"); +} + +#CODE +my $machine = $ip; +$port and $machine .= ":$port"; +$user and $machine = $user . '@' . $machine; + +# access_modify validates all its parameters, don't do it ourselves here for clarity +$fnret = OVH::Bastion::access_modify( + way => 'group', + action => $action, + group => $group, + ip => $ip, + user => $user, + port => $port, + ttl => $ttl, + comment => $comment, +); +if ($fnret->err eq 'OK') { + my $ttlmsg = $ttl ? ' (expires in ' . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'} . ')' : ''; + HEXIT('OK', msg => $action eq 'add' ? "Entry $machine was added to group $shortGroup$ttlmsg" : "Entry $machine was removed from group $shortGroup$ttlmsg"); +} +elsif ($fnret->err eq 'OK_NO_CHANGE') { + HEXIT('OK_NO_CHANGE', + msg => $action eq 'add' ? "Entry $machine was already added to group $shortGroup, nothing done" : "Entry $machine was not in group $shortGroup, nothing done"); +} +HEXIT($fnret); diff --git a/bin/helper/osh-groupAddSymlinkToAccount b/bin/helper/osh-groupAddSymlinkToAccount new file mode 100755 index 0000000..0ee0063 --- /dev/null +++ b/bin/helper/osh-groupAddSymlinkToAccount @@ -0,0 +1,147 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as a gatekeeper, to be able to symlink in /home/allowkeeper/ACCOUNT the /home/%GROUP%/allowed.ip file +# KEYSUDOERS SUPEROWNERS, %%GROUP%-gatekeeper ALL=(allowkeeper) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupAddSymlinkToAccount --group %GROUP% * +# FILEMODE 0750 +# FILEOWN root allowkeeper + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $group, $action); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "group=s" => sub { $group //= $_[1] }, # ignore subsequent --group on cmdline (anti-sudoers-override) + "action=s" => sub { $action //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $account or not $group or not $action) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account', 'group' or 'action'"); +} + +if (not grep { $action eq $_ } qw{ add del }) { + HEXIT('ERR_INVALID_PARAMETER', msg => "Argument action should be either 'add' or 'del'"); +} + +#
PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; +my $sysaccount = $fnret->value->{'sysaccount'}; +my $remoteaccount = $fnret->value->{'remoteaccount'}; + +#PARAMS:GROUP +# test if start by key, append if necessary +if ($group !~ /^key/) { + $group = "key$group"; +} +osh_debug("Checking group"); +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + $fnret = OVH::Bastion::is_group_gatekeeper(account => $self, group => $shortGroup, superowner => 1, sudo => 1); + if (!$fnret) { + warn_syslog("$0: account $self is not a $shortGroup gatekeeper, refused to continue"); + HEXIT('ERR_NOT_ALLOWED', msg => "Sorry, you're not a gatekeeper of group $shortGroup"); + } +} + +#CODE +my $msg; +my $prefix = $remoteaccount ? "allowed_$remoteaccount" : "allowed"; +my $link = "/home/allowkeeper/$sysaccount/$prefix.ip.$shortGroup"; +if ($action eq 'del') { + osh_debug("Going to remove symlink"); + if (-l $link || -e _) { + if (unlink $link) { + $msg = "Successfully removed $link"; + } + else { + warn_syslog("$0: error while trying to remove symlink $link ($!)"); + HEXIT('ERR_UNLINK_FAILED', msg => "Error while trying to remove symlink"); + } + } + else { + HEXIT('OK_NO_CHANGE', msg => "Symlink was not existing as $link, nothing to do"); + } +} +elsif ($action eq 'add') { + my $source = "/home/$group/allowed.ip"; + osh_debug("symlinking $source to $link"); + + if (not -e $source) { + HEXIT('ERR_SOURCE_NOT_FOUND', msg => "Cannot create symlink as $source doesn't exist"); + } + elsif (-e $link) { + HEXIT('OK_NO_CHANGE', msg => "Symlink $link is already there, nothing to do"); + } + else { + if (symlink($source, $link)) { + $msg = "Account $account now has full access to $shortGroup servers"; + } + else { + warn_syslog("$0: error while creating symlink $source to $link ($!)"); + HEXIT('ERR_SYMLINK_FAILED', msg => "Error while creating symlink"); + } + } +} +else { + warn_syslog("$0: unreachable code has been reached"); + HEXIT('ERR_INTERNAL'); # unreachable +} + +HEXIT("OK", msg => $msg); diff --git a/bin/helper/osh-groupCreate b/bin/helper/osh-groupCreate new file mode 100755 index 0000000..22a88ee --- /dev/null +++ b/bin/helper/osh-groupCreate @@ -0,0 +1,287 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-groupCreate +# SUDOERS %osh-groupCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-groupCreate * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use Term::ReadKey; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Bastion::Plugin::groupSetRole; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($group, $owner, $algo, $size, $encrypted, $no_key, $comment); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "group=s" => sub { $group //= $_[1] }, + "owner=s" => sub { $owner //= $_[1] }, + "algo=s" => sub { $algo //= $_[1] }, + "size=i" => sub { $size //= $_[1] }, + "encrypted" => sub { $encrypted //= $_[1] }, + "no-key" => sub { $no_key //= $_[1] }, + "comment=s" => sub { $comment //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$group || !$owner) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'group' or 'owner'"); +} +if ($no_key && ($algo || $size || $encrypted)) { + EXIT('ERR_INVALID_PARAMETER', msg => "Can't specify 'no-key' along with 'algo', 'size' or 'encrypted'"); +} +if (!$no_key && (!$algo || !$size)) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'algo' or 'size'"); +} +if ($comment) { + if ($comment =~ /^([a-zA-Z0-9=_,-]+)$/) { + $comment = $1; # untaint + } + else { + HEXIT('ERR_INVALID_PARAMETER', msg => "Specified comment contains invalid characters"); + } +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-groupCreate"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:ACCOUNT +osh_debug("Checking owner"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $owner); +$fnret or HEXIT($fnret); + +# get returned untainted value +$owner = $fnret->value->{'account'}; + +#PARAMS:GROUP +osh_debug("checking group"); +$fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key"); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +foreach my $test ($group, "$group-gatekeeper", "$group-owner") { + $fnret = OVH::Bastion::is_group_existing(group => $test); + $fnret->is_err and HEXIT($fnret); + my (undef, $displayGroup) = $test =~ m/^(key)?(.+)/; + $fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The group $displayGroup already exists"); +} + +$fnret = OVH::Bastion::is_account_existing(account => $group); +$fnret->is_err and HEXIT($fnret); +$fnret->is_ok and HEXIT('KO_ALREADY_EXISTING', msg => "The account $group already exists"); + +#PARAMS:ALGO/SIZE +if (!$no_key) { + $algo = lc($algo); + $fnret = OVH::Bastion::is_allowed_algo_and_size(algo => $algo, size => $size, way => 'egress'); + $fnret or HEXIT($fnret); + + # if we're still here, it's valid, untaint those + ($algo) = $algo =~ m/(.+)/; + ($size) = $size =~ m/(.+)/; +} + +#CODE +my $passphrase = ''; # empty by default +if ($encrypted) { + print STDERR "Please enter a passphrase for the new group key (not echoed): "; + ReadMode('noecho'); + chomp(my $pass1 = ); + if (length($pass1) < 5) { + ReadMode('restore'); + HEXIT('ERR_PASSPHRASE_TOO_SMALL', msg => "Passphrase should have at least 5 chars"); + } + print STDERR "\nPlease enter it again: "; + chomp(my $pass2 = ); + print STDERR "\n"; + ReadMode('restore'); + if ($pass1 ne $pass2) { + HEXIT('ERR_PASSPHRASE_MISMATCH', msg => "Passphrases don't match, please try again"); + } + ($passphrase) = $pass1 =~ /(.+)/; # untaint +} + +# First create group +osh_info("Creating groups"); +foreach my $tocreate ($group, "$group-aclkeeper", "$group-gatekeeper", "$group-owner") { + $fnret = OVH::Bastion::sys_groupadd(group => $tocreate, noisy_stderr => 1); + $fnret->err eq 'OK' + or HEXIT('ERR_GROUPADD_FAILED', msg => "Error while running groupadd command for $tocreate (" . $fnret->msg . ")"); +} + +osh_debug("Creating directory"); +mkdir "/home/keykeeper/$group"; +chmod 0755, "/home/keykeeper/$group"; + +osh_info("Creating user corresponding to group $shortGroup"); + +# if a comment has been set, we'll store it as the user's GECOS corresponding to the group name +# user is member of the group, cannot login and have no password +$fnret = OVH::Bastion::sys_useradd(user => $group, gid => $group, shell => undef, comment => $comment, noisy_stderr => 1); +$fnret->err eq 'OK' + or HEXIT('ERR_USERADD_FAILED', msg => "Error while adding corresponding user of group $shortGroup (" . $fnret->msg . ")"); + +# Building /home/$group +OVH::Bastion::touch_file("/home/$group/allowed.ip"); + +osh_debug("Adding allowkeeper to group $group"); +$fnret = OVH::Bastion::add_user_to_group(group => $group, user => 'allowkeeper', groupType => 'key'); +$fnret or HEXIT($fnret); + +osh_info("Adding $owner to owner, gatekeeper, aclkeeper and main groups of $shortGroup"); + +# temporarily set ourselves owner manually so that we can add the wanted owner properly +# as owner/gatekeeper/member then revoke our own right +$fnret = OVH::Bastion::sys_addmembertogroup(group => "$group-owner", user => $self, noisy_stderr => 1); +$fnret or HEXIT($fnret); + +# special case: if we're setting ourselves as owner, we must not remove +# our own rights after granting +my @todoList = ( + $owner eq $self + ? ( + {action => 'add', type => 'owner', account => $owner}, + {action => 'add', type => 'aclkeeper', account => $owner}, + {action => 'add', type => 'gatekeeper', account => $owner}, + {action => 'add', type => 'member', account => $owner}, + ) + : ( + {action => 'add', type => 'owner', account => $owner}, + {action => 'add', type => 'aclkeeper', account => $owner}, + {action => 'add', type => 'gatekeeper', account => $owner}, + {action => 'add', type => 'gatekeeper', account => $self}, + {action => 'add', type => 'member', account => $owner}, + {action => 'del', type => 'gatekeeper', account => $self}, + {action => 'del', type => 'owner', account => $self}, + ) +); + +foreach my $todo (@todoList) { + $fnret = OVH::Bastion::Plugin::groupSetRole::act( + self => $self, + account => $todo->{'account'}, + group => $shortGroup, + action => $todo->{'action'}, + type => $todo->{'type'}, + sudo => 1, + silentoverride => 1 + ); + $fnret or HEXIT($fnret); +} + +my $keykeeper_uid = (getpwnam('keykeeper'))[2]; +my $group_gid = (getgrnam($group))[2]; +chown $keykeeper_uid, $group_gid, "/home/keykeeper/$group"; +if (!$no_key) { + osh_info("Generating main group key, this might take a few seconds..."); + + $fnret = OVH::Bastion::generate_ssh_key( + prefix => $shortGroup, + folder => "/home/keykeeper/$group", + size => $size, + algo => $algo, + passphrase => $passphrase, + uid => $keykeeper_uid, + gid => $group_gid, + group_readable => 1 + ); + $fnret or HEXIT($fnret); +} + +osh_info("Adjusting permissions..."); +my $bigX = (OVH::Bastion::is_linux() ? 'X' : 'x'); +foreach my $command ( + ['chown', '-R', "$group:$group", "/home/$group"], + ['chgrp', "$group-aclkeeper", "/home/$group/allowed.ip"], + ['chmod', '-R', "o-rwx,g=r$bigX,u=rw$bigX", "/home/$group"], + ['chmod', '0664', "/home/$group/allowed.ip"], + ) +{ + $fnret = OVH::Bastion::execute(cmd => $command, noisy_stderr => 1); + $fnret->err eq 'OK' + or HEXIT('ERR_CHMOD_FAILED', msg => "Error while running chmod to adjust permissions (" . $fnret->msg . ")"); +} +chmod 0751, "/home/$group" if !OVH::Bastion::has_acls(); + +foreach my $gr ("$group-owner", "$group-gatekeeper", "$group-aclkeeper", "osh-whoHasAccessTo", "osh-auditor") { + OVH::Bastion::sys_setfacl(target => "/home/$group", perms => "g:$gr:x") + or HEXIT('ERR_SETFACL_FAILED', msg => "Error setting ACLs on group homedir"); +} + +# allowed to sudo for the group +osh_info("Configuring sudoers for this group"); +my $sudoers_dir = OVH::Bastion::sys_getsudoersfolder(); + +if (-e "$sudoers_dir/osh-group-$group") { + osh_debug "sudoers already in place, but overwriting it"; +} + +$fnret = OVH::Bastion::execute(cmd => [$OVH::Bastion::BASEPATH . '/bin/sudogen/generate-sudoers.sh', 'group', $group], must_succeed => 1, noisy_stdout => 1); +$fnret or HEXIT('ERR_CANNOT_CREATE_SUDOERS', msg => "An error occurred while creating sudoers for this group"); + +OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'group', + fields => [ + ['action', 'create'], + ['group', $shortGroup], + ['owner', $owner], + ['egress_ssh_key_algorithm', $algo], + ['egress_ssh_key_size', $size], + ['egress_ssh_key_encrypted', ($encrypted ? 'true' : 'false')], + ] +); + +# done at last! +HEXIT('OK', value => {group => $shortGroup, owner => $owner}); diff --git a/bin/helper/osh-groupDelete b/bin/helper/osh-groupDelete new file mode 100755 index 0000000..2e97e53 --- /dev/null +++ b/bin/helper/osh-groupDelete @@ -0,0 +1,196 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# NEEDGROUP osh-groupDelete +# SUDOERS %osh-groupDelete ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-groupDelete * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use File::Copy qw(move); + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($group); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("group=s" => sub { $group //= $_[1] },); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$group) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'group'"); +} + +#
RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + # need to perform another security check + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-groupDelete"); + if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } +} + +#PARAMS:GROUP +# test if start by key, append if necessary +osh_debug("Checking group"); +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +#CODE +# last security check +if (not -e "/home/$group/allowed.ip" or not -e "/home/keykeeper/$group") { + HEXIT('ERR_INVALID_GROUP', msg => "Sorry, but $shortGroup doesn't seem to be a legit bastion group"); +} + +if (not -d "/home/oldkeeper") { + mkdir "/home/oldkeeper"; +} +chown 0, 0, "/home/oldkeeper"; +chmod 0700, "/home/oldkeeper"; + +if (!-d "/home/oldkeeper/groups") { + mkdir "/home/oldkeeper/groups"; +} +chown 0, 0, "/home/oldkeeper/groups"; +chmod 0700, "/home/oldkeeper/groups"; + +my $suffix = 'at-' . time() . '.by-' . $self; + +my $fulldir = "/home/oldkeeper/groups/$group.$suffix"; +if (-e $fulldir) { + exitError("Errr... $fulldir exists?!"); +} + +mkdir $fulldir; +chown 0, 0, $fulldir; +chmod 0700, $fulldir; + +move("/home/$group", "$fulldir/$group-home"); +move("/home/keykeeper/$group", "$fulldir/$group-keykeeper"); + +# now tar.gz the directory, this is important because inside we'll keep the +# old GID of the group, and we don't want GID-orphaned files on our filesystem, it's +# not a problem to have those inside a tarfile however. +my @tarcmd = qw{ tar czf }; +push @tarcmd, $fulldir . '.tar.gz'; +push @tarcmd, '--acls' if OVH::Bastion::has_acls(); +push @tarcmd, '--one-file-system', '-p', '--remove-files', $fulldir; +$fnret = OVH::Bastion::execute(cmd => \@tarcmd, must_succeed => 1); +if (!$fnret) { + osh_warn("Couldn't tar the backup homedir of this group (" . $fnret->msg . "), proceeding anyway."); + chmod 0000, $fulldir; +} +else { + chmod 0000, $fulldir . '.tar.gz'; + unlink($fulldir); +} + +# remove dead symlinks in users homes +my $dh; +if (opendir($dh, "/home/allowkeeper")) { + while (my $dir = readdir($dh)) { + $dir =~ /^\./ and next; + $dir !~ /^([a-zA-Z0-9._-]+)$/ and next; + $dir = "/home/allowkeeper/$1"; # and untaint + -d $dir or next; + foreach my $file ("$dir/allowed.ip.$shortGroup", "$dir/allowed.partial.$shortGroup") { + if (-e $file || -l $file) { + osh_info "Removing $file..."; + unlink($file); + } + } + } + close($dh); +} +else { + osh_warn("Couldn't open /home/allowkeeper ?!"); +} + +# trying to remove main and gatekeeper and owner groups +foreach my $todelete ("$group-owner", "$group-aclkeeper", "$group-gatekeeper", $group) { + $fnret = OVH::Bastion::is_group_existing(group => $todelete); + if ($fnret) { + $todelete = $fnret->value->{'group'}; # untaint + my $members = $fnret->value->{'members'} || []; + if (@$members) { + osh_info "Found " . (scalar @$members) . " members, removing them from the group"; + foreach my $member (@$members) { + osh_info "... removing $member from group $todelete"; + $fnret = OVH::Bastion::sys_delmemberfromgroup(user => $member, group => $todelete, noisy_stderr => 1); + $fnret->err eq 'OK' + or HEXIT('ERR_DELUSER_FAILED', msg => "Error while attempting to remove member $member from group $todelete (" . $fnret->msg . ")"); + } + } + + if ($todelete eq $group) { + osh_info "Deleting main user of group $todelete...", $fnret = OVH::Bastion::sys_userdel(user => $todelete, noisy_stderr => 1); + $fnret->err eq 'OK' + or HEXIT('ERR_DELUSER_FAILED', msg => "Error while attempting to delete main user of group $todelete (" . $fnret->msg . ")"); + } + + # some OSes delete the main group of user if it has the same name + # and nobody else is a member of it, so check it still exists before + # trying to delete it + $fnret = OVH::Bastion::is_group_existing(group => $todelete); + if ($fnret) { + osh_info "Deleting group $todelete..."; + $fnret = OVH::Bastion::sys_groupdel(group => $todelete, noisy_stderr => 1); + $fnret + or HEXIT('ERR_DELGROUP_FAILED', msg => "Error while attempting to delete group $todelete (" . $fnret->msg . ")"); + } + } + else { + osh_info "Group $todelete not found, ignoring..."; + } +} + +# remove sudoers if it's there +unlink(OVH::Bastion::sys_getsudoersfolder() . "/osh-group-$group"); + +OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'group', + fields => [['action', 'delete'], ['group', $shortGroup],] +); + +HEXIT('OK'); diff --git a/bin/helper/osh-groupGeneratePassword b/bin/helper/osh-groupGeneratePassword new file mode 100755 index 0000000..af3ebcd --- /dev/null +++ b/bin/helper/osh-groupGeneratePassword @@ -0,0 +1,56 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as an owner, we can generate an egress password for the group +# KEYSUDOERS SUPEROWNERS, %%GROUP%-owner ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupGeneratePassword --group %GROUP% * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin::generatePassword; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my ($result, @optwarns); +my ($group, $size); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "group=s" => sub { $group //= $_[1] }, # ignore subsequent --group on cmdline (anti-sudoers-override) + "size=i" => sub { $size //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $size or not $group) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'size' or 'group'"); +} + +#
$self, context => 'group', group => $group, size => $size, sudo => 1)); diff --git a/bin/helper/osh-groupModify b/bin/helper/osh-groupModify new file mode 100755 index 0000000..e24a14e --- /dev/null +++ b/bin/helper/osh-groupModify @@ -0,0 +1,128 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as an owner, we can modify the group settings +# KEYSUDOERS SUPEROWNERS, %%GROUP%-owner ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupModify --group %GROUP% * +# FILEMODE 0755 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Result; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +Getopt::Long::Configure("no_auto_abbrev"); +my $fnret; +my ($result, @optwarns); +my ($group, $mfaRequired, $ttl); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "group=s" => sub { $group //= $_[1] }, + "mfa-required=s" => \$mfaRequired, + "guest-ttl-limit=i" => \$ttl, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$group) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'group'"); +} + +if (!$mfaRequired && !defined $ttl) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'mfa-required' or 'guest-ttl-limit'"); +} + +#
PARAMS:ACCOUNT +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +$fnret = OVH::Bastion::is_group_owner(account => $self, group => $shortGroup, superowner => 1, sudo => 1); +if (!$fnret) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +#CODE +my %result; + +if (defined $mfaRequired) { + osh_info "Modifying mfa-required policy of group..."; + if (grep { $mfaRequired eq $_ } qw{ password totp any none }) { + $fnret = OVH::Bastion::group_config(group => $group, key => "mfa_required", value => $mfaRequired); + if ($fnret) { + osh_info "... done, policy is now: $mfaRequired"; + } + else { + osh_warn "... error while changing mfa-required policy (" . $fnret->msg . ")"; + } + $result{'mfa_required'} = $fnret; + } + else { + osh_warn "... invalid option '$mfaRequired'"; + $result{'mfa_required'} = R('ERR_INVALID_PARAMETER'); + } +} + +if (defined $ttl) { + osh_info "Modifying guest TTL limit policy of group..."; + if ($ttl > 0) { + $fnret = OVH::Bastion::group_config(group => $group, key => "guest_ttl_limit", value => $ttl); + if ($fnret) { + osh_info "... done, guest accesses must now have a TTL set on creation, with maximum allowed duration of " + . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'}; + } + else { + osh_warn "... error while setting guest-ttl-limit (" . $fnret->msg . ")"; + } + } + else { + $fnret = OVH::Bastion::group_config(group => $group, key => "guest_ttl_limit", delete => 1); + if ($fnret) { + osh_info "... done, guest accesses no longer need to have a TTL set"; + } + else { + osh_warn "... error while removing guest-ttl-limit (" . $fnret->msg . ")"; + } + } + $result{'guest_ttl_limit'} = $fnret; +} + +HEXIT('OK', value => \%result); diff --git a/bin/helper/osh-groupSetRole b/bin/helper/osh-groupSetRole new file mode 100755 index 0000000..7b906e5 --- /dev/null +++ b/bin/helper/osh-groupSetRole @@ -0,0 +1,140 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as an owner, we can grant/revoke ownership +# KEYSUDOERS SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type owner --group %GROUP% * +# KEYSUDOERS # as an owner, we can grant/revoke gatekeepership +# KEYSUDOERS SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type gatekeeper --group %GROUP% * +# KEYSUDOERS # as an owner, we can grant/revoke aclkeepership +# KEYSUDOERS SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type aclkeeper --group %GROUP% * +# KEYSUDOERS # as a gatekeeper, we can grant/revoke membership +# KEYSUDOERS SUPEROWNERS, %%GROUP%-gatekeeper ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type member --group %GROUP% * +# KEYSUDOERS # as a gatekeeper, we can grant/revoke a guest access +# KEYSUDOERS SUPEROWNERS, %%GROUP%-gatekeeper ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type guest --group %GROUP% * +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin::groupSetRole; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +Getopt::Long::Configure("no_auto_abbrev"); +my $fnret; +my ($result, @optwarns); +my ($account, $group, $action, $type); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "type=s" => sub { $type //= $_[1] }, + "action=s" => sub { $action //= $_[1] }, + "group=s" => sub { $group //= $_[1] }, # ignore subsequent --group on cmdline (anti-sudoers-override) + "account=s" => sub { $account //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +osh_debug("groupSetRole: checking preconditions"); +$fnret = OVH::Bastion::Plugin::groupSetRole::preconditions(self => $self, account => $account, group => $group, action => $action, type => $type, sudo => 1, silentoverride => 1); +osh_debug("groupSetRole: checking preconditions result: $fnret"); +$fnret or HEXIT($fnret); + +my $shortGroup; +my %values = %{$fnret->value()}; +($group, $shortGroup, $account, $type) = @values{qw{ group shortGroup account type }}; +my ($sysaccount, $realm, $remoteaccount) = @values{qw{ sysaccount realm remoteaccount }}; + +#RIGHTSCHECK +#done in Plugin::groupSetRole::preconditions +#CODE +if ($type eq 'owner') { + $fnret = OVH::Bastion::is_group_owner(account => $account, group => $shortGroup, sudo => 1); +} +elsif ($type eq 'gatekeeper') { + $fnret = OVH::Bastion::is_group_gatekeeper(account => $account, group => $shortGroup, sudo => 1); +} +elsif ($type eq 'aclkeeper') { + $fnret = OVH::Bastion::is_group_aclkeeper(account => $account, group => $shortGroup, sudo => 1); +} +elsif ($type eq 'member') { + $fnret = OVH::Bastion::is_group_member(account => $account, group => $shortGroup, sudo => 1); +} +elsif ($type eq 'guest') { + $fnret = OVH::Bastion::is_group_guest(account => $account, group => $shortGroup, sudo => 1); +} +$fnret->is_err and HEXIT($fnret); + +if ($action eq 'add' && $fnret->is_ok) { + osh_debug("groupSetRole: Account $account was already a $type of group $shortGroup, nothing to do"); + HEXIT('OK_NO_CHANGE', msg => "Account $account was already a $type of group $shortGroup, nothing to do"); +} +elsif ($action eq 'del' && $fnret->is_ko) { + osh_debug("groupSetRole: Account $account was not a $type of group $shortGroup, nothing to do"); + HEXIT('OK_NO_CHANGE', msg => "Account $account was not a $type of group $shortGroup, nothing to do"); +} + +# add/del from sysgroup +my $groupName = ((grep { $type eq $_ } qw{ guest member }) ? $group : "$group-$type"); + +osh_debug("going to $action account $account to/from $groupName"); +$fnret = R('OK', silent => 1); +if ($action eq 'add') { + + if (!OVH::Bastion::is_user_in_group(user => $sysaccount, group => $groupName)) { + $fnret = OVH::Bastion::sys_addmembertogroup(group => $groupName, user => $sysaccount, noisy_stderr => 1); + } +} +elsif ($action eq 'del') { + + # for realms, maybe we must not delete the shared realm account from the group, if other remote users are still members + my $otherMembers = 0; + if ($realm) { + $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $realm); + $fnret or HEXIT($fnret); + foreach my $pRemoteaccount (@{$fnret->value}) { + next if ($pRemoteaccount eq $remoteaccount); + $otherMembers++ if OVH::Bastion::is_group_member(account => "$realm/$pRemoteaccount", group => $shortGroup, sudo => 1); + } + } + if (!$otherMembers) { + $fnret = OVH::Bastion::sys_delmemberfromgroup(group => $groupName, user => $sysaccount, noisy_stderr => 1); + } +} +else { + HEXIT('ERR_INTERNAL'); # unreachable +} +if ($fnret->err ne 'OK') { + osh_debug('Unable to modify group: ' . $fnret->msg); + HEXIT('ERR_INTERNAL', msg => "Error while doing $action on account $account from $type list of $shortGroup"); +} +osh_debug("groupSetRole: Account $action of $account done on $type list of $shortGroup"); +HEXIT('OK', msg => "Account $action of $account done on $type list of $shortGroup"); diff --git a/bin/helper/osh-selfMFASetupPassword b/bin/helper/osh-selfMFASetupPassword new file mode 100755 index 0000000..a8758a4 --- /dev/null +++ b/bin/helper/osh-selfMFASetupPassword @@ -0,0 +1,112 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account, $step); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "account=s" => sub { $account //= $_[1] }, + "step=i" => sub { $step //= $_[1] }, + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (!$account || !defined $step) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account' or 'step'"); +} + +#>RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +elsif ($self ne $account) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +# $account)); +} +elsif ($step == 1) { + + # set a temporary password + my $password = sprintf("%04d-%04d-%04d-%04d", rand(10000), rand(10000), rand(10000), rand(10000)); + $fnret = OVH::Bastion::sys_changepassword(user => $account, password => $password); + $fnret or HEXIT($fnret); + + # force password change in 1 day max (it should be done several seconds after anyway) + $fnret = OVH::Bastion::sys_setpasswordpolicy( + user => $account, + inactiveDays => OVH::Bastion::config('MFAPasswordInactiveDays')->value, + minDays => 0, + maxDays => 1, + warnDays => 1 + ); + $fnret or HEXIT($fnret); + + HEXIT('OK', value => {password => $password}); +} + +elsif ($step == 2) { + $fnret = OVH::Bastion::sys_addmembertogroup(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP); + $fnret or HEXIT($fnret); + + # now that we have the final password set, apply the bastionwide password policy + $fnret = OVH::Bastion::sys_setpasswordpolicy( + user => $account, + inactiveDays => OVH::Bastion::config('MFAPasswordInactiveDays')->value, + minDays => OVH::Bastion::config('MFAPasswordMinDays')->value, + maxDays => OVH::Bastion::config('MFAPasswordMaxDays')->value, + warnDays => OVH::Bastion::config('MFAPasswordWarnDays')->value + ); + $fnret or HEXIT($fnret); + + HEXIT('OK'); +} + +HEXIT('ERR_INVALID_PARAMETER', msg => "Parameter --step expects 0, 1 or 2"); diff --git a/bin/helper/osh-selfMFASetupTOTP b/bin/helper/osh-selfMFASetupTOTP new file mode 100755 index 0000000..57417a5 --- /dev/null +++ b/bin/helper/osh-selfMFASetupTOTP @@ -0,0 +1,71 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# FILEMODE 0700 +# FILEOWN root root + +#>HEADER +use common::sense; +use Getopt::Long; +use File::Copy; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +local $| = 1; + +# +# Globals +# +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; +my ($self) = $ENV{'SUDO_USER'} =~ m{^([a-zA-Z0-9._-]+)$}; +if (not defined $self) { + if ($< == 0) { + $self = 'root'; + } + else { + HEXIT('ERR_SUDO_NEEDED', msg => 'This command must be run under sudo'); + } +} + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my ($account); +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions("account=s" => sub { $account //= $_[1] }); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +if (not $account) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); +} + +#>RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +elsif ($self ne $account) { + HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); +} + +#PARAMS:ACCOUNT +osh_debug("Checking account"); +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or HEXIT($fnret); +$account = $fnret->value->{'account'}; # untainted + +# $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP); +$fnret or HEXIT($fnret); + +HEXIT('OK'); diff --git a/bin/other/check-active-account-fortestsonly.pl b/bin/other/check-active-account-fortestsonly.pl new file mode 100755 index 0000000..433d606 --- /dev/null +++ b/bin/other/check-active-account-fortestsonly.pl @@ -0,0 +1,32 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +# DO NOT USE THIS SCRIPT IN PRODUCTION! +# This is only used for the functional tests, it returns true for odd UIDs, false otherwise. +# If you think this is a good way of determining your users activeness, you might want to revise your security procedures. + +use constant { + EXIT_ACTIVE => 0, + EXIT_INACTIVE => 1, + EXIT_UNKNOWN => 2, + EXIT_UNKNOWN_SILENT_ERROR => 3, + EXIT_UNKNOWN_NOISY_ERROR => 4, +}; + +sub failtest { + my $msg = shift || "Error"; + print STDERR "$msg. This will fail the test: MAKETESTFAIL\n"; + exit EXIT_UNKNOWN_NOISY_ERROR; +} + +my $sysaccount = shift; +if (!$sysaccount) { + failtest("No account name to check"); +} + +my $uid = getpwnam($sysaccount); +failtest("Can't find this account") if not defined $uid; + +exit EXIT_ACTIVE if ($uid % 2 == 0); +exit EXIT_INACTIVE; diff --git a/bin/other/check-active-account-simple.pl b/bin/other/check-active-account-simple.pl new file mode 100755 index 0000000..fd738ac --- /dev/null +++ b/bin/other/check-active-account-simple.pl @@ -0,0 +1,54 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +# This is an basic script to check whether an account is active or not. +# It serves as an example of what such a script can look like, but can also be used +# as is in production if it matches your use case. +# See the 'accountExternalValidationProgram' option in bastion.conf for more information + +use constant { + EXIT_ACTIVE => 0, + EXIT_INACTIVE => 1, + EXIT_UNKNOWN => 2, + EXIT_UNKNOWN_SILENT_ERROR => 3, + EXIT_UNKNOWN_NOISY_ERROR => 4, +}; + +my $sysaccount = shift; +if (!$sysaccount) { + print STDERR "No account name to check. Report this to sysadmin!\n"; + exit EXIT_UNKNOWN_NOISY_ERROR; +} + +# This file should be a simple plaintext file containing one account name per line +# It should be populated by e.g. a cron script that queries some external directory +# such as an LDAP for example. +# Ensure that this file is readable at least by the bastion-users system group! +my $file = '/home/allowkeeper/active_accounts.txt'; + +if (!(-e $file)) { + + print STDERR "Active accounts file is not present. Report this to sysadmin!\n"; + exit EXIT_UNKNOWN_NOISY_ERROR; +} + +# Load file +my $f; +if (!(open $f, '<', $file)) { + print STDERR "Active logins file is unreadable ($!). Report this to sysadmin!\n"; + exit EXIT_UNKNOWN_NOISY_ERROR; +} + +# check that the account is present in the file +while (<$f>) { + chomp; + if ($_ eq $sysaccount) { + close($f); + exit EXIT_ACTIVE; + } +} +close($f); + +# If not, account is inactive +exit EXIT_INACTIVE; diff --git a/bin/plugin/admin/adminMaintenance b/bin/plugin/admin/adminMaintenance new file mode 100755 index 0000000..e0b29a4 --- /dev/null +++ b/bin/plugin/admin/adminMaintenance @@ -0,0 +1,46 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "maintenance", + options => { + "lock" => \my $lock, + "unlock" => \my $unlock, + "message=s" => \my $message, + }, + helptext => <<'EOF', +Manage the bastion maintenance mode + +Usage: --osh SCRIPT_NAME <--lock [--message "'reason for maintenance'"]|--unlock> + + --lock Set maintenance mode: new logins will be disallowed + --unlock Unset maintenance mode: new logins are allowed and the bastion functions normally + --message MESSAGE Optionally set a maintenance reason, if you're in a shell, quote it twice. +EOF +); + +if (!$lock && !$unlock) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected --lock or --unlock"; +} + +if ($lock && $unlock) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "Got both --lock and --unlock, what are your trying to do exactly?"; +} + +my @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-adminMaintenance'; +push @command, "--action", ($lock ? 'set' : 'unset'); +push @command, "--message", $message if $message; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/admin/adminMaintenance.json b/bin/plugin/admin/adminMaintenance.json new file mode 100644 index 0000000..5b45950 --- /dev/null +++ b/bin/plugin/admin/adminMaintenance.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "adminMaintenance" , { "ac" : ["--lock","--unlock"]}, + "adminMaintenance --lock" , { "ac" : ["--message",""]}, + "adminMaintenance --lock --message" , { "pr" : ["\"\""]}, + "adminMaintenance --lock --message .+" , { "pr" : [""]}, + "adminMaintenance --unlock" , { "pr" : [""]} + ] +} diff --git a/bin/plugin/admin/adminSudo b/bin/plugin/admin/adminSudo new file mode 100755 index 0000000..437016b --- /dev/null +++ b/bin/plugin/admin/adminSudo @@ -0,0 +1,73 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "launching a bastion command or connection, impersonating another user", + options => { + "sudo-as=s" => \my $sudoAs, + "sudo-cmd=s" => \my $sudoCmd, + }, + helptext => <<'EOF', +Impersonate another user + +Usage: --osh SCRIPT_NAME -- --sudo-as ACCOUNT <--sudo-cmd PLUGIN -- [PLUGIN specific options...]> + + --sudo-as ACCOUNT Specify which bastion account we want to impersonate + --sudo-cmd PLUGIN --osh command we want to launch as the user (see --osh help) + +Example:: + + --osh SCRIPT_NAME -- --sudo-as user12 --sudo-cmd info -- --name somebodyelse + +Don't forget the double-double-dash as seen in the example above: one after the plugin name, +and another one to separate SCRIPT_NAME options from the options of the plugin to be called. +EOF +); + +my $fnret; + +if (not $sudoAs or not $sudoCmd) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'sudo-as' or 'sudo-cmd'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $sudoAs); +$fnret or osh_exit($fnret); + +$fnret = OVH::Bastion::can_account_execute_plugin(account => $sudoAs, plugin => $sudoCmd); +$fnret or osh_exit($fnret); + +my @cmd = qw( sudo -n -u ); +push @cmd, $sudoAs; +push @cmd, qw( -- /usr/bin/env perl ); +push @cmd, $OVH::Bastion::BASEPATH . '/bin/shell/osh.pl'; +push @cmd, '-c'; + +my $stringified; +$stringified = " --osh $sudoCmd" if $sudoCmd; +$stringified .= " --host $host" if $host; +$stringified .= " --port $port" if $port; +$stringified .= " --user $user" if $user; +$stringified .= " " . join(" ", @$remainingOptions) if ($remainingOptions and @$remainingOptions); + +push @cmd, $stringified; + +OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'admin-sudo'], ['account', $self], ['sudo-as', $sudoAs], ['plugin', ($sudoCmd ? $sudoCmd : 'ssh')], ['params', $stringified]] +); + +osh_warn("ADMIN SUDO: $self, you'll now impersonate $sudoAs, this has been logged."); + +$fnret = OVH::Bastion::execute(cmd => \@cmd, noisy_stdout => 1, noisy_stderr => 1); + +osh_exit $fnret; diff --git a/bin/plugin/admin/adminSudo.json b/bin/plugin/admin/adminSudo.json new file mode 100644 index 0000000..3b0dda3 --- /dev/null +++ b/bin/plugin/admin/adminSudo.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "adminSudo" , { "ac" : ["-- --sudo-as"]}, + "adminSudo -- --sudo-as" , { "ac" : ["" ]}, + "adminSudo -- --sudo-as \\S+" , { "ac" : ["--sudo-cmd" ]}, + "adminSudo -- --sudo-as \\S+ --sudo-cmd" , { "pr" : [" -- " ]} + ] +} diff --git a/bin/plugin/group-aclkeeper/groupAddServer b/bin/plugin/group-aclkeeper/groupAddServer new file mode 100755 index 0000000..6e435f6 --- /dev/null +++ b/bin/plugin/group-aclkeeper/groupAddServer @@ -0,0 +1,144 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "adding a server to a group", + options => { + "group=s" => \my $group, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + "force" => \my $force, # for slashes, and/or for servers that are down (no connection test) + "force-key" => \my $forceKey, + "ttl=s" => \my $ttl, + "comment=s" => \my $comment, + }, + helptext => <<'EOF', +Add an IP or IP block to a group's servers list + +Usage: --osh SCRIPT_NAME --group GROUP [OPTIONS] + + --group GROUP Specify which group this machine should be added to (it should have the public group key of course) + --host HOST|IP|NET/CIDR Host(s) to add access to, either a HOST which will be resolved to an IP immediately, or an IP, + or a whole network using the NET/CIDR notation + --user USER Specify which remote user should be allowed (root, run, etc...) + --user-any Allow any remote user (the remote user should still have the public group key in all cases) + --port PORT Only allow access to this port (e.g. 22) + --port-any Allow access to any port + --scpup Allow SCP upload, you--bastion-->server (omit --user in this case) + --scpdown Allow SCP download, you<--bastion--server (omit --user in this case) + --force Don't try the ssh connection, just add the host to the group blindly + --force-key FINGERPRINT Only use the key with the specified fingerprint to connect to the server (cf groupInfo) + --ttl SECONDS|DURATION Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + --comment '"ANY TEXT'" Add a comment alongside this server + +Examples:: + + --osh SCRIPT_NAME --group grp1 --host 203.0.113.0/24 --user-any --port-any --force --comment '"a whole network"' + --osh SCRIPT_NAME --group grp2 --host srv1.example.org --user root --port 22 +EOF +); + +my $fnret; + +if (not $group or not $ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'host' or 'group' (or host didn't resolve correctly)"; +} + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "A user was specified, but --user-any used, these are incompatible, please think about what you're doing"; +} + +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} + +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to add this server with any user, add --user-any"; +} + +if ($portAny and $port) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "A port was specified, but --port-any used, these are incompatible, please think about what you're doing"; +} + +if (not $port and not $portAny) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to add this server with any port, add --port-any"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +if ($forceKey) { + $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $forceKey); + $fnret or osh_exit $fnret; + $forceKey = $fnret->value->{'fingerprint'}; +} + +# +# Now do it +# + +$fnret = OVH::Bastion::is_group_aclkeeper(account => $self, group => $shortGroup, superowner => 1); +$fnret or osh_exit 'ERR_NOT_GROUP_ACLKEEPER', "Sorry, you must be an aclkeeper of group $shortGroup to be able to add servers to it"; + +if (not $force) { + $fnret = OVH::Bastion::ssh_test_access_way(group => $group, user => $user, port => $port, ip => $ip, forceKey => $forceKey); + if ($fnret->is_ok and $fnret->err ne 'OK') { + + # we have something to say, say it + osh_info $fnret->msg; + } + elsif (not $fnret) { + osh_info "Note: if you still want to add this access even if it doesn't work, use --force"; + osh_exit $fnret; + } +} +else { + osh_info "Forcing add as asked, we didn't test the SSH connection, maybe it won't work!"; +} + +my @command = qw{ sudo -n -u }; +push @command, ($group, '--', '/usr/bin/env', 'perl', '-T', $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupAddServer'); +push @command, '--group', $group; +push @command, '--action', 'add'; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; +push @command, '--force-key', $forceKey if $forceKey; +push @command, '--ttl', $ttl if $ttl; +push @command, '--comment', $comment if $comment; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/group-aclkeeper/groupAddServer.json b/bin/plugin/group-aclkeeper/groupAddServer.json new file mode 100644 index 0000000..f68bd75 --- /dev/null +++ b/bin/plugin/group-aclkeeper/groupAddServer.json @@ -0,0 +1,14 @@ +{ + "interactive": [ + "groupAddServer" , {"ac" : ["--group"]}, + "groupAddServer --group" , {"ac" : [""]}, + "groupAddServer --group \\S+" , {"ac" : ["--host"]}, + "groupAddServer --group \\S+ --host" , {"pr" : ["", "", ""]}, + "groupAddServer --group \\S+ --host \\S+" , {"ac" : ["--port", "--port-any"]}, + "groupAddServer --group \\S+ --host \\S+ --port" , {"pr" : [""]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+)" , {"ac" : ["--user", "--user-any"]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+) --user" , {"pr" : [""]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+) --user(-any| \\S+)" , {"pr" : ["", "--force"]} + ], + "master_only": true +} diff --git a/bin/plugin/group-aclkeeper/groupDelServer b/bin/plugin/group-aclkeeper/groupDelServer new file mode 100755 index 0000000..2cbb13e --- /dev/null +++ b/bin/plugin/group-aclkeeper/groupDelServer @@ -0,0 +1,100 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "removing a server from a group", + options => { + "group=s" => \my $group, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + "force" => \my $force, + }, + helptext => <<'EOF', +Remove an IP or IP block from a group's serrver list + +Usage: --osh SCRIPT_NAME --group GROUP [OPTIONS] + + --group GROUP Specify which group this machine should be removed from + --host HOST|IP|NET/CIDR Host(s) we want to remove access to + --user USER Remote user that was allowed, if any user was allowed, use --user-any + --user-any Use if any remote login was allowed + --port PORT Remote SSH port that was allowed, if any port was allowed, use --port-any + --port-any Use if any remote port was allowed + --scpup Remove SCP upload right, you--bastion-->server (omit --user in this case) + --scpdown Remove SCP download right, you<--bastion--server (omit --user in this case) +EOF +); + +my $fnret; + +if (not $group or not $ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'host' or 'group' (or host didn't resolve correctly)"; +} + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "A user was specified, but --user-any used, these are incompatible, please think about what you're doing"; +} + +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} + +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to add this server with any user, add --user-any"; +} + +if ($portAny and $port) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "A port was specified, but --port-any used, these are incompatible, please think about what you're doing"; +} + +if (not $port and not $portAny) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to add this server with any port, add --port-any"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +# +# Now do it +# + +$fnret = OVH::Bastion::is_group_aclkeeper(account => $self, group => $shortGroup, superowner => 1); +$fnret or osh_exit 'ERR_NOT_GROUP_ACLKEEPER', "Sorry, you must be an aclkeeper of group $shortGroup to be able to delete servers from it"; + +my @command = qw{ sudo -n -u }; +push @command, ($group, '--', '/usr/bin/env', 'perl', '-T', $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupAddServer'); +push @command, '--group', $group; +push @command, '--action', 'del'; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/group-aclkeeper/groupDelServer.json b/bin/plugin/group-aclkeeper/groupDelServer.json new file mode 100644 index 0000000..f68bd75 --- /dev/null +++ b/bin/plugin/group-aclkeeper/groupDelServer.json @@ -0,0 +1,14 @@ +{ + "interactive": [ + "groupAddServer" , {"ac" : ["--group"]}, + "groupAddServer --group" , {"ac" : [""]}, + "groupAddServer --group \\S+" , {"ac" : ["--host"]}, + "groupAddServer --group \\S+ --host" , {"pr" : ["", "", ""]}, + "groupAddServer --group \\S+ --host \\S+" , {"ac" : ["--port", "--port-any"]}, + "groupAddServer --group \\S+ --host \\S+ --port" , {"pr" : [""]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+)" , {"ac" : ["--user", "--user-any"]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+) --user" , {"pr" : [""]}, + "groupAddServer --group \\S+ --host \\S+ --port(-any| \\d+) --user(-any| \\S+)" , {"pr" : ["", "--force"]} + ], + "master_only": true +} diff --git a/bin/plugin/group-gatekeeper/groupAddGuestAccess b/bin/plugin/group-gatekeeper/groupAddGuestAccess new file mode 100755 index 0000000..4a10340 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupAddGuestAccess @@ -0,0 +1,93 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "add access to one server of a group to an account", + options => { + "group=s" => \my $group, + "account=s" => \my $account, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + "ttl=s" => \my $ttl, + }, + helptext => <<'EOF', +Add a specific group server access to an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT [OPTIONS] + + --group GROUP group to add guest access to + --account ACCOUNT name of the other bastion account to add access to, he'll be given access to the GROUP key + --host HOST|IP add access to this HOST (which must belong to the GROUP) + --user USER allow connecting to HOST only with remote login USER + --user-any allow connecting to HOST with any remote login + --port PORT allow connecting to HOST only to remote port PORT + --port-any allow connecting to HOST with any remote port + --scpup allow SCP upload, you--bastion-->server (omit --user in this case) + --scpdown allow SCP download, you<--bastion--server (omit --user in this case) + --ttl SECONDS|DURATION Specify a number of seconds after which the access will automatically expire + +This command adds, to an existing bastion account, access to the egress keys of a group, +but only to accessing one or several given servers, instead of all the servers of this group. + +If you want to add complete access to an account to all the present and future servers +of the group, using the group key, please use ``groupAddMember`` instead. + +If you want to add access to an account to a group server but using his personal bastion +key instead of the group key, please use ``accountAddPersonalAccess`` instead (his public key +must be on the remote server). + +This command is the opposite of ``groupDelGuestAccess``. +EOF +); + +if (not $ip and $host) { + osh_exit 'ERR_INVALID_HOST', "Specified host ($host) didn't resolve correctly, fix your DNS or specify the IP instead"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +if (defined $ttl) { + my $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +my $realUser = $user; +$realUser = '!scpupload' if $scpUp; +$realUser = '!scpdownload' if $scpDown; +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'guest', + user => $realUser, + userAny => $userAny, + port => $port, + portAny => $portAny, + host => ($ip || $host), + ttl => $ttl, + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-gatekeeper/groupAddGuestAccess.json b/bin/plugin/group-gatekeeper/groupAddGuestAccess.json new file mode 100644 index 0000000..6222ec5 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupAddGuestAccess.json @@ -0,0 +1,17 @@ +{ + "interactive": [ + "groupAddGuestAccess" , {"ac" : ["--account"]}, + "groupAddGuestAccess --account" , {"ac" : [""]}, + "groupAddGuestAccess --account \\S+" , {"ac" : ["--group"]}, + "groupAddGuestAccess --account \\S+ --group" , {"ac" : [""]}, + "groupAddGuestAccess --account \\S+ --group \\S+" , {"ac" : ["--host"]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host" , {"pr" : ["", "", ""]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+ .*--user" , {"pr" : [""]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+ .*--port" , {"pr" : [""]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "groupAddGuestAccess --account \\S+ --group \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-gatekeeper/groupAddMember b/bin/plugin/group-gatekeeper/groupAddMember new file mode 100755 index 0000000..77c5603 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupAddMember @@ -0,0 +1,46 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "grant an account as member of a group", + options => { + "account=s" => \my $account, + "group=s" => \my $group, + }, + helptext => <<'EOF', +Add an account to the member list + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as a member of + --account ACCOUNT which account to set as a member of GROUP + +The specified account will be able to access all present and future servers +pertaining to this group. +If you need to give a specific and/or temporary access instead, +see ``groupAddGuestAccess`` +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'member', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-gatekeeper/groupAddMember.json b/bin/plugin/group-gatekeeper/groupAddMember.json new file mode 100644 index 0000000..b251417 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupAddMember.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupAddMember" , {"ac" : ["--account"]}, + "groupAddMember --account" , {"ac" : [""]}, + "groupAddMember --account \\S+" , {"ac" : ["--group"]}, + "groupAddMember --account \\S+ --group" , {"ac" : [""]}, + "groupAddMember --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-gatekeeper/groupDelGuestAccess b/bin/plugin/group-gatekeeper/groupDelGuestAccess new file mode 100755 index 0000000..e6e6774 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupDelGuestAccess @@ -0,0 +1,84 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove access from one server of a group from an account", + options => { + "group=s" => \my $group, + "account=s" => \my $account, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + }, + helptext => <<'EOF', +Remove a specific group server access from an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT [OPTIONS] + + --group GROUP group to remove guest access from + --account ACCOUNT name of the other bastion account to remove access from + --host HOST|IP remove access from this HOST (which must belong to the GROUP) + --user USER allow connecting to HOST only with remote login USER + --user-any allow connecting to HOST with any remote login + --port PORT allow connecting to HOST only to remote port PORT + --port-any allow connecting to HOST with any remote port + --scpup allow SCP upload, you--bastion-->server (omit --user in this case) + --scpdown allow SCP download, you<--bastion--server (omit --user in this case) + +This command removes, from an existing bastion account, access to a given server, using the +egress keys of the group. The list of such servers is given by ``groupListGuestAccesses`` + +If you want to remove member access from an account to all the present and future servers +of the group, using the group key, please use ``groupDelMember`` instead. + +If you want to remove access from an account from a group server but using his personal bastion +key instead of the group key, please use ``accountDelPersonalAccess`` instead. + +This command is the opposite of ``groupAddGuestAccess``. +EOF +); + +if (not $ip and $host) { + osh_exit 'ERR_INVALID_HOST', "Specified host ($host) didn't resolve correctly, fix your DNS or specify the IP instead"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} + +my $realUser = $user; +$realUser = '!scpupload' if $scpUp; +$realUser = '!scpdownload' if $scpDown; +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'del', + type => 'guest', + user => $realUser, + userAny => $userAny, + port => $port, + portAny => $portAny, + host => ($ip || $host), + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-gatekeeper/groupDelGuestAccess.json b/bin/plugin/group-gatekeeper/groupDelGuestAccess.json new file mode 100644 index 0000000..160503c --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupDelGuestAccess.json @@ -0,0 +1,17 @@ +{ + "interactive": [ + "groupDelGuestAccess" , {"ac" : ["--account"]}, + "groupDelGuestAccess --account" , {"ac" : [""]}, + "groupDelGuestAccess --account \\S+" , {"ac" : ["--group"]}, + "groupDelGuestAccess --account \\S+ --group" , {"ac" : [""]}, + "groupDelGuestAccess --account \\S+ --group \\S+" , {"ac" : ["--host"]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host" , {"pr" : ["", "", ""]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+ .*--user" , {"pr" : [""]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+ .*--port" , {"pr" : [""]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "groupDelGuestAccess --account \\S+ --group \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-gatekeeper/groupDelMember b/bin/plugin/group-gatekeeper/groupDelMember new file mode 100755 index 0000000..376cbbc --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupDelMember @@ -0,0 +1,46 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "revoke an account as member of a group", + options => { + "account=s" => \my $account, + "group=s" => \my $group, + }, + helptext => <<'EOF', +Remove an account from the members list + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to remove ACCOUNT as a member of + --account ACCOUNT which account to remove as a member of GROUP + +The specified account will no longerr be able to access all present and future servers +pertaining to this group. +Note that if this account also had specific guest accesses to this group, they may +still apply, see ``groupListGuestAccesses`` +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'del', + type => 'member', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-gatekeeper/groupDelMember.json b/bin/plugin/group-gatekeeper/groupDelMember.json new file mode 100644 index 0000000..915d2ca --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupDelMember.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupDelMember" , {"ac" : ["--account"]}, + "groupDelMember --account" , {"ac" : [""]}, + "groupDelMember --account \\S+" , {"ac" : ["--group"]}, + "groupDelMember --account \\S+ --group" , {"ac" : [""]}, + "groupDelMember --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-gatekeeper/groupListGuestAccesses b/bin/plugin/group-gatekeeper/groupListGuestAccesses new file mode 100755 index 0000000..70366c9 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupListGuestAccesses @@ -0,0 +1,52 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($group, $account, $reverse); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "lists partial access to group servers of a bastion account", + options => { + "group=s" => \$group, + "account=s" => \$account, + "reverse-dns" => \$reverse, + }, + helptext => <<'EOF', +List the guest accesses to servers of a group specifically granted to an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP Look for accesses to servers of this GROUP + --account ACCOUNT Which account to check +EOF +); + +my $fnret; + +if (not $group or not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account' or 'group'"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit $fnret; + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +$fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => $account); +$fnret or osh_exit $fnret; + +if (not @{$fnret->value}) { + osh_ok R('OK_EMPTY', msg => "This account doesn't seem to have any guest access to this group"); +} + +OVH::Bastion::print_acls(acls => [{type => 'group-guest', group => $shortGroup, acl => $fnret->value}], reverse => $reverse); +osh_ok($fnret->value); diff --git a/bin/plugin/group-gatekeeper/groupListGuestAccesses.json b/bin/plugin/group-gatekeeper/groupListGuestAccesses.json new file mode 100644 index 0000000..6fe1592 --- /dev/null +++ b/bin/plugin/group-gatekeeper/groupListGuestAccesses.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "groupListGuestAccesses" , {"ac" : ["--account"]}, + "groupListGuestAccesses --account" , {"ac" : [""]}, + "groupListGuestAccesses --account \\S+" , {"ac" : ["--group"]}, + "groupListGuestAccesses --account \\S+ --group" , {"ac" : [""]}, + "groupListGuestAccesses --account \\S+ --group \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/group-owner/groupAddAclkeeper b/bin/plugin/group-owner/groupAddAclkeeper new file mode 100755 index 0000000..2754c0a --- /dev/null +++ b/bin/plugin/group-owner/groupAddAclkeeper @@ -0,0 +1,40 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "grant an account as aclkeeper of a group", + options => {"account=s" => \my $account, "group=s" => \my $group}, + helptext => <<'EOF', +Add the group aclkeeper role to an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as an aclkeeper of + --account ACCOUNT which account to set as an aclkeeper of GROUP + +The specified account will be able to manage the server list of this group +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'aclkeeper', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupAddAclkeeper.json b/bin/plugin/group-owner/groupAddAclkeeper.json new file mode 100644 index 0000000..7665c54 --- /dev/null +++ b/bin/plugin/group-owner/groupAddAclkeeper.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupAddAclkeeper" , {"ac" : ["--account"]}, + "groupAddAclkeeper --account" , {"ac" : [""]}, + "groupAddAclkeeper --account \\S+" , {"ac" : ["--group"]}, + "groupAddAclkeeper --account \\S+ --group" , {"ac" : [""]}, + "groupAddAclkeeper --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupAddGatekeeper b/bin/plugin/group-owner/groupAddGatekeeper new file mode 100755 index 0000000..6e7beb3 --- /dev/null +++ b/bin/plugin/group-owner/groupAddGatekeeper @@ -0,0 +1,42 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my ($account, $group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "grant an account as gatekeeper of a group", + options => {"account=s", \$account, "group=s", \$group}, + helptext => <<'EOF', +Add the group gatekeeper role to an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as a gatekeeper of + --account ACCOUNT which account to set as a gatekeeper of GROUP + +The specified account will be able to manage the members list of this group, +along with the guests list +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'gatekeeper', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupAddGatekeeper.json b/bin/plugin/group-owner/groupAddGatekeeper.json new file mode 100644 index 0000000..1e08756 --- /dev/null +++ b/bin/plugin/group-owner/groupAddGatekeeper.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupAddGatekeeper" , {"ac" : ["--account"]}, + "groupAddGatekeeper --account" , {"ac" : [""]}, + "groupAddGatekeeper --account \\S+" , {"ac" : ["--group"]}, + "groupAddGatekeeper --account \\S+ --group" , {"ac" : [""]}, + "groupAddGatekeeper --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupAddOwner b/bin/plugin/group-owner/groupAddOwner new file mode 100755 index 0000000..10f2e2f --- /dev/null +++ b/bin/plugin/group-owner/groupAddOwner @@ -0,0 +1,44 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my ($account, $group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "grant an account as owner of a group", + options => {"account=s", \$account, "group=s", \$group}, + helptext => <<'EOF', +Add the group owner role to an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as an owner of + --account ACCOUNT which account to set as an owner of GROUP + +The specified account will be able to manage the owner, gatekeeper +and aclkeeper list of this group. In other words, this account will +have all possible rights to manage the group and delegate some or all +of the rights to other accounts +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'owner', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupAddOwner.json b/bin/plugin/group-owner/groupAddOwner.json new file mode 100644 index 0000000..6b3cc25 --- /dev/null +++ b/bin/plugin/group-owner/groupAddOwner.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupAddOwner" , {"ac" : ["--account"]}, + "groupAddOwner --account" , {"ac" : [""]}, + "groupAddOwner --account \\S+" , {"ac" : ["--group"]}, + "groupAddOwner --account \\S+ --group" , {"ac" : [""]}, + "groupAddOwner --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupDelAclkeeper b/bin/plugin/group-owner/groupDelAclkeeper new file mode 100755 index 0000000..22f1509 --- /dev/null +++ b/bin/plugin/group-owner/groupDelAclkeeper @@ -0,0 +1,40 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "revoke an account as aclkeeper of a group", + options => {"account=s" => \my $account, "group=s" => \my $group}, + helptext => <<'EOF', +Remove the group aclkeeper role from an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to remove ACCOUNT as an aclkeeper of + --account ACCOUNT which account to remove as an aclkeeper of GROUP + +The specified account will no longer be able to manage the server list of this group +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'del', + type => 'aclkeeper', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupDelAclkeeper.json b/bin/plugin/group-owner/groupDelAclkeeper.json new file mode 100644 index 0000000..1e29b8b --- /dev/null +++ b/bin/plugin/group-owner/groupDelAclkeeper.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupDelAclkeeper" , {"ac" : ["--account"]}, + "groupDelAclkeeper --account" , {"ac" : [""]}, + "groupDelAclkeeper --account \\S+" , {"ac" : ["--group"]}, + "groupDelAclkeeper --account \\S+ --group" , {"ac" : [""]}, + "groupDelAclkeeper --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupDelGatekeeper b/bin/plugin/group-owner/groupDelGatekeeper new file mode 100755 index 0000000..65289da --- /dev/null +++ b/bin/plugin/group-owner/groupDelGatekeeper @@ -0,0 +1,42 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my ($account, $group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "revoke an account as gatekeeper of a group", + options => {"account=s", \$account, "group=s", \$group}, + helptext => <<'EOF', +Remove the group gatekeeper role from an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to remove ACCOUNT as a gatekeeper of + --account ACCOUNT which account to remove as a gatekeeper of GROUP + +The specified account will no longer be able to manager the members nor +the guest list of this group +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'del', + type => 'gatekeeper', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupDelGatekeeper.json b/bin/plugin/group-owner/groupDelGatekeeper.json new file mode 100644 index 0000000..4a24755 --- /dev/null +++ b/bin/plugin/group-owner/groupDelGatekeeper.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupDelGatekeeper" , {"ac" : ["--account"]}, + "groupDelGatekeeper --account" , {"ac" : [""]}, + "groupDelGatekeeper --account \\S+" , {"ac" : ["--group"]}, + "groupDelGatekeeper --account \\S+ --group" , {"ac" : [""]}, + "groupDelGatekeeper --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupDelOwner b/bin/plugin/group-owner/groupDelOwner new file mode 100755 index 0000000..201fe9f --- /dev/null +++ b/bin/plugin/group-owner/groupDelOwner @@ -0,0 +1,42 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my ($account, $group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "revoke an account as owner of a group", + options => {"account=s", \$account, "group=s", \$group}, + helptext => <<'EOF', +Remove the group owner role from an account + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as an owner of + --account ACCOUNT which account to set as an owner of GROUP + +The specified account will no longer be able to manage the owner, +gatekeeper and aclkeeper lists of this group +EOF +); + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'del', + type => 'owner', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +help() if not $fnret; +osh_exit($fnret); diff --git a/bin/plugin/group-owner/groupDelOwner.json b/bin/plugin/group-owner/groupDelOwner.json new file mode 100644 index 0000000..9f96114 --- /dev/null +++ b/bin/plugin/group-owner/groupDelOwner.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupDelOwner" , {"ac" : ["--account"]}, + "groupDelOwner --account" , {"ac" : [""]}, + "groupDelOwner --account \\S+" , {"ac" : ["--group"]}, + "groupDelOwner --account \\S+ --group" , {"ac" : [""]}, + "groupDelOwner --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/group-owner/groupGeneratePassword b/bin/plugin/group-owner/groupGeneratePassword new file mode 100755 index 0000000..9971c36 --- /dev/null +++ b/bin/plugin/group-owner/groupGeneratePassword @@ -0,0 +1,79 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::generatePassword; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "generating a new egress password for the group", + options => { + "size=i" => \my $size, + "group=s" => \my $group, + "do-it" => \my $doIt, + }, + helptext => <<'EOF' +Generate a new egress password for the group + +Usage: --osh SCRIPT_NAME --group GROUP [--size SIZE] --do-it + + --group GROUP Specify which group you want to generate a password for + --size SIZE Specify the number of characters of the password to generate + --do-it Required for the password to actually be generated, BEWARE: please read the note below + +Generate a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for the group, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. +EOF +); + +# code +my $fnret; + +$size = 16 if not defined $size; + +if (not $group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected a --group argument"; +} + +$fnret = OVH::Bastion::Plugin::generatePassword::preconditions(self => $self, context => 'group', group => $group, size => $size); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +if (not $doIt) { + help(); + osh_exit('ERR_MISSING_PARAMETER', "Missing mandatory parameter: please read the BEWARE note above."); +} + +my @command = qw{ sudo -n -u }; +push @command, $group; +push @command, qw{ -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupGeneratePassword'; +push @command, "--group", $group, "--size", $size; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit($fnret); + +osh_info "Generated a new password of length $size for group $shortGroup, hashes follow:"; +osh_info "md5crypt: " . $fnret->value->{'hashes'}{'md5crypt'} . "\n"; +osh_info "sha256crypt: " . $fnret->value->{'hashes'}{'sha256crypt'} . "\n"; +osh_info "sha512crypt: " . $fnret->value->{'hashes'}{'sha512crypt'} . "\n"; +osh_info "This new password will now be used by default."; +osh_exit $fnret; diff --git a/bin/plugin/group-owner/groupGeneratePassword.json b/bin/plugin/group-owner/groupGeneratePassword.json new file mode 100644 index 0000000..9df6641 --- /dev/null +++ b/bin/plugin/group-owner/groupGeneratePassword.json @@ -0,0 +1,11 @@ +{ + "interactive": [ + "groupGeneratePassword" , {"ac" : ["--group"]}, + "groupGeneratePassword --group" , {"ac" : [""]}, + "groupGeneratePassword --group \\S+" , {"ac" : ["", "--size"]}, + "groupGeneratePassword --group \\S+ --size" , {"pr" : [""]}, + "groupGeneratePassword --group \\S+ --size \\d+" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/group-owner/groupModify b/bin/plugin/group-owner/groupModify new file mode 100755 index 0000000..c63418a --- /dev/null +++ b/bin/plugin/group-owner/groupModify @@ -0,0 +1,65 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "modify the configuration of a group", + options => { + "group=s" => \my $group, + "mfa-required=s" => \my $mfaRequired, + "guest-ttl-limit=s" => \my $ttl, + }, + helptext => <<'EOF', +Modify the configuration of a group + +Usage: --osh SCRIPT_NAME --group GROUP [--mfa-required password|totp|any|none] [--guest-ttl-limit DURATION] + + --group GROUP Name of the group to modify + --mfa-required password|totp|any|none Enforce UNIX password requirement, or TOTP requirement, or any MFA requirement, when connecting to a server of the group + --guest-ttl-limit DURATION This group will enforce TTL setting, on guest access creation, to be set, and not to a higher value than DURATION, + set to zero to allow guest accesses creation without any TTL set (default) +EOF +); + +my $fnret; + +if (!$group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'group'"; +} +if (!$mfaRequired && !defined $ttl) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Nothing to modify"; +} +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); +$fnret or osh_exit $fnret; +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +if (defined $mfaRequired && !grep { $mfaRequired eq $_ } qw{ password totp any none }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected 'password', 'totp', 'any' or 'none' as parameter to --mfa-required"; +} + +my @command = qw{ sudo -n -u }; +push @command, $group; +push @command, qw{ -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupModify'; +push @command, '--group', $group; +push @command, '--mfa-required', $mfaRequired if $mfaRequired; +push @command, '--guest-ttl-limit', $ttl if defined $ttl; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/group-owner/groupModify.json b/bin/plugin/group-owner/groupModify.json new file mode 100644 index 0000000..7f90021 --- /dev/null +++ b/bin/plugin/group-owner/groupModify.json @@ -0,0 +1,11 @@ +{ + "interactive": [ + "groupModify" , {"ac" : ["--group"]}, + "groupModify --group" , {"ac" : [""]}, + "groupModify --group \\S+" , {"ac" : ["--mfa-required","--guest-ttl-limit"]}, + "groupModify --group \\S+ --mfa-required" , {"ac" : ["password","totp","any","none"]}, + "groupModify --group \\S+ --mfa-required \\S+" , {"pr" : [""]}, + "groupModify --group \\S+ --guest-ttl-limit" , {"pr" : [""]}, + "groupModify --group \\S+ --guest-ttl-limit \\S+", {"pr" : [""]} + ] +} diff --git a/bin/plugin/group-owner/groupTransmitOwnership b/bin/plugin/group-owner/groupTransmitOwnership new file mode 100755 index 0000000..4440699 --- /dev/null +++ b/bin/plugin/group-owner/groupTransmitOwnership @@ -0,0 +1,62 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::groupSetRole; + +my ($account, $group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "transmit the ownership of a group", + options => {"account=s", \$account, "group=s", \$group}, + helptext => <<'EOF', +Transmit your group ownership to somebody else + +Usage: --osh SCRIPT_NAME --group GROUP --account ACCOUNT + + --group GROUP which group to set ACCOUNT as an owner of + --account ACCOUNT which account to set as an owner of GROUP + +Note that this command has the same net effect than using ``groupAddOwner`` +to add ACCOUNT as an owner, then removing yourself with ``groupDelOwner`` +EOF +); + +if ($self eq $account) { + osh_exit(R('OK_NO_CHANGE', msg => "Nothing to do to transmit the ownership of the group to yourself")); +} + +my $fnret = OVH::Bastion::Plugin::groupSetRole::act( + account => $account, + group => $group, + action => 'add', + type => 'owner', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs +); +if (not $fnret) { + help(); + osh_exit($fnret); +} + +osh_exit( + OVH::Bastion::Plugin::groupSetRole::act( + account => $self, + group => $group, + action => 'del', + type => 'owner', + sudo => 0, + silentoverride => 0, + self => $self, + scriptName => $scriptName, + savedArgs => $savedArgs + ) +); diff --git a/bin/plugin/group-owner/groupTransmitOwnership.json b/bin/plugin/group-owner/groupTransmitOwnership.json new file mode 100644 index 0000000..5f54454 --- /dev/null +++ b/bin/plugin/group-owner/groupTransmitOwnership.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "groupTransmitOwnership" , {"ac" : ["--account"]}, + "groupTransmitOwnership --account" , {"ac" : [""]}, + "groupTransmitOwnership --account \\S+" , {"ac" : ["--group"]}, + "groupTransmitOwnership --account \\S+ --group" , {"ac" : [""]}, + "groupTransmitOwnership --account \\S+ --group \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/open/alive b/bin/plugin/open/alive new file mode 100755 index 0000000..6c3d34e --- /dev/null +++ b/bin/plugin/open/alive @@ -0,0 +1,78 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Time::HiRes (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "ping until host is alive", + options => {}, + helptext => <<'EOF', +Ping a host and exist as soon as it answers + +This command can be used to monitor a host that is expected to go back online soon. +Note that if you want to ssh to it afterwards, you can simply use the ``--wait`` main option. + +Usage: --osh SCRIPT_NAME [--host] HOSTNAME + + --host HOSTNAME hostname or IP to ping +EOF +); + +# be nice and try to guessify a user@host as first param +# if user said --osh alive usah@mymachine.example.org +if ( not $host + and not $ip + and not $user + and ref $remainingOptions eq 'ARRAY' + and @$remainingOptions == 1 + and $remainingOptions->[0] =~ /^([a-zA-Z0-9_-]+\@)?([a-zA-Z0-9][a-zA-Z0-9.-]{1,})$/) +{ + $user = $1; + $host = $2; + $user =~ s/\@$//; +} + +# +# code +# +my $fnret; + +if (not $host) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing required host parameter"; +} + +osh_info "Waiting for $host to be alive..."; +my $startedat = Time::HiRes::gettimeofday(); +my $isFping = 1; +my @command = qw{ fping -- }; +push @command, $host; +while (1) { + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1); + if ($fnret->err eq 'ERR_EXEC_FAILED') { + osh_exit $fnret if $command[0] eq 'ping'; + @command = qw{ ping -c 1 -w 1 -- }; + push @command, $host; + $isFping = 0; + next; + } + $fnret or osh_exit $fnret; + if ($fnret->value->{'sysret'} == 0) { + my $delay = int(Time::HiRes::gettimeofday() - $startedat); + osh_info "Alive after waiting for $delay seconds, exiting!"; + osh_ok {waited_for => $delay + 0}; + } + elsif (($isFping && $fnret->value->{'sysret'} >= 3) || ($fnret->value->{'sysret'} > 0)) { + osh_exit 'ERR_INTERNAL', "Fatal error returned by (f)ping, aborting"; + } + sleep 1; +} + +osh_exit 'ERR_INTERNAL'; diff --git a/bin/plugin/open/alive.json b/bin/plugin/open/alive.json new file mode 100644 index 0000000..acd4410 --- /dev/null +++ b/bin/plugin/open/alive.json @@ -0,0 +1,6 @@ +{ + "interactive": [ + "alive" , {"pr" : [""]}, + "alive \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/batch b/bin/plugin/open/batch new file mode 100755 index 0000000..342ee78 --- /dev/null +++ b/bin/plugin/open/batch @@ -0,0 +1,66 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "clush", + options => {}, + helptext => <<'EOF', +Run a batch of osh commands fed through STDIN + +Usage: --osh SCRIPT_NAME + +**Examples:** + +(replace ``bssh`` by your bastion alias) + +- run 3 simple commands in a oneliner: + +:: + + printf "%b\n%b\n%b" info selfListIngressKeys selfListEgressKeys | bssh --osh batch + +- run a lot of commands written out line by line in a file: + +:: + + bssh --osh batch < cmdlist.txt + +- add 3 users to a group: + +:: + + for i in user1 user2 user3; do echo "groupAddMember --account $i --group grp4"; done | bssh --osh batch +EOF +); + +my $fnret; + +osh_info "Feed me osh commands line by line on stdin, I'll execute them sequentially."; +osh_info "Use 'exit', 'quit' or ^D to stop."; +osh_info "--- waiting for input"; + +my @ret; +while (my $line = ) { + chomp $line; + last if (lc($line) eq 'exit' || lc($line) eq 'quit'); + + # we remove any json param, because we add --json ourselves so that we can decode + # what the osh plugin had to say, and add it to our global @ret json + $line =~ s/ --json[a-z-]*//g; + my @cmd = ($ENV{'SHELL'}, '-c', "--osh $line --json"); + osh_info "--- launching command: $line"; + $fnret = OVH::Bastion::helper(cmd => \@cmd); + if (!$fnret) { + osh_warn "--- command failed!"; + } + push @ret, {command => $line, result => $fnret}; +} +osh_ok(\@ret); diff --git a/bin/plugin/open/clush b/bin/plugin/open/clush new file mode 100755 index 0000000..58f9bdd --- /dev/null +++ b/bin/plugin/open/clush @@ -0,0 +1,106 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($list, $stepByStep, $noPauseOnFailure, $noConfirm, $noStdin, $command); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "clush", + options => { + 'list=s' => \$list, + 'step-by-step' => \$stepByStep, + 'no-pause-on-failure' => \$noPauseOnFailure, + 'no-confirm' => \$noConfirm, + 'no-stdin' => \$noStdin, + 'command=s' => \$command, + }, + helptext => <<'EOF', +Launch a remote command on several machines sequentially (clush-like) + +Usage: --osh SCRIPT_NAME [OPTIONS] --command '"remote command"' + + --list HOSTLIST Comma-separated list of the hosts to run the command on + --step-by-step Pause before running the command on each host + --no-pause-on-failure Don't pause if the remote command doesn't return failed (returned != 0) + --no-confirm Skip confirmation of the host list and command + --command '"remote cmd"' Command to be run on the remote hosts. If you're in a shell, quote it twice as shown. +EOF +); + +# +# code +# +my $fnret; + +# +# params check +# + +if (not $list) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'list'"; +} + +if (not $command) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'command'"; +} + +# +# and test external command call +# +my %hosts; +foreach my $host (split /,/, $list) { + if ($host !~ /^[a-zA-Z0-9.-]+$/) { + osh_exit 'ERR_INVALID_PARAMETER', "This doesn't appear to be a valid host '$host'"; + } + $hosts{$host} = 1; +} +my @hosts = keys %hosts; + +if (not @hosts) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Host list to execute the command on is empty"; +} + +osh_info "Will execute a command on " . scalar(@hosts) . " hosts ($list)"; +osh_info "The command to be executed is: $command"; + +if (not $noConfirm) { + osh_info "Press ENTER to proceed, or CTRL+C to cancel."; + ; +} + +my %ret; +foreach my $host (@hosts) { + osh_info "************************"; + osh_info "Working on $host"; + osh_info "************************"; + if ($stepByStep) { + osh_info("Press ENTER to execute the command on this host"); + ; + } + + my $shellc = $host; + $user and $shellc = "$user\@$host"; + $port and $shellc .= " --port $port"; + + # relaunch the user's shell (aka osh.pl) to connect to the host + my @cmd = ($ENV{'SHELL'}, '-c', "--quiet $shellc -- $command"); + + $fnret = OVH::Bastion::execute(cmd => \@cmd, noisy_stderr => 1, noisy_stdout => 1, expects_stdin => $noStdin ? 0 : 1); + if ($fnret and $fnret->value() and $fnret->value()->{'sysret'} != 0 and not $noPauseOnFailure) { + osh_warn("Last command failed (return code: " . $fnret->value()->{'sysret'} . "), press ENTER to continue or CTRL+C to stop here"); + ; + } + + $ret{$host} = {stderr => $fnret->value->{'stderr'}, stdout => $fnret->value->{'stdout'}, sysret => $fnret->value->{'sysret'}}; +} + +osh_ok(\%ret); diff --git a/bin/plugin/open/groupInfo b/bin/plugin/open/groupInfo new file mode 100755 index 0000000..0e2773b --- /dev/null +++ b/bin/plugin/open/groupInfo @@ -0,0 +1,203 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($group); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "group info", + options => {'group=s' => \$group}, + helptext => <<'EOF', +Print some basic information about a group + +Usage: --osh SCRIPT_NAME --group GROUP + + --group GROUP specify the group to display the infos of +EOF +); + +# +# code +# +my $fnret; + +# +# params check +# +if (!$group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'group' parameter"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +my %roles; +foreach my $role (qw{ member aclkeeper gatekeeper owner }) { + $fnret = OVH::Bastion::is_group_existing(group => $group . ($role eq 'member' ? '' : "-$role")); + if (!$fnret) { + osh_exit($fnret) if $role eq 'member'; # critical + $roles{$role} = []; + } + else { + $roles{$role} = [grep { $_ ne 'allowkeeper' } @{$fnret->value->{'members'} || []}]; + } +} + +my $result_hash = {group => $shortGroup}; +$result_hash->{'owners'} = $roles{'owner'}; +$result_hash->{'aclkeepers'} = $roles{'aclkeeper'}; +$result_hash->{'gatekeepers'} = $roles{'gatekeeper'}; + +osh_info "Group " . $shortGroup . "'s Owners are: " . colored(@{$roles{'owner'}} ? join(" ", sort @{$roles{'owner'}}) : '-', "red"); +osh_info "Group " + . $shortGroup + . "'s GateKeepers (managing the members/guests list) are: " + . colored(@{$roles{'gatekeeper'}} ? join(" ", sort @{$roles{'gatekeeper'}}) : '-', "red"); +if ( OVH::Bastion::is_group_owner(group => $shortGroup, account => $self, superowner => 1) + || OVH::Bastion::is_group_gatekeeper(group => $shortGroup, account => $self) + || OVH::Bastion::is_group_aclkeeper(group => $shortGroup, account => $self) + || OVH::Bastion::is_group_member(group => $shortGroup, account => $self) + || OVH::Bastion::is_auditor(account => $self)) +{ + + osh_info "Group " + . $shortGroup + . "'s ACLKeepers (managing the group servers list) are: " + . colored(@{$roles{'aclkeeper'}} ? join(" ", sort @{$roles{'aclkeeper'}}) : '-', "red"); + + # now, who is member / guest ? + my (@members, @guests); + foreach my $account (@{$roles{'member'}}) { + osh_debug("what is $account?"); + if ($account =~ /^realm_(.+)/) { + my $pRealm = $1; + $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $pRealm); + if (!$fnret || !@{$fnret->value}) { + + # we couldn't get the list, or the list is empty: at least show that the realm shared account is there + push @members, $user; + } + else { + foreach my $pRemoteaccount (@{$fnret->value}) { + if (OVH::Bastion::is_group_guest(group => $shortGroup, account => "$pRealm/$pRemoteaccount")) { + push @guests, "$pRealm/$pRemoteaccount"; + } + else { + push @members, "$pRealm/$pRemoteaccount"; + } + } + } + } + else { + + if (OVH::Bastion::is_group_guest(account => $account, group => $shortGroup)) { + push @guests, $account; + } + else { + push @members, $account; + } + } + } + osh_info "Group " . $shortGroup . "'s Members (with access to ALL the group servers) are: " . colored(@members ? join(" ", sort @members) : '-', "red"); + + my %guest_details; + my @guest_text; + my @filtered_guests; + foreach my $guest (sort @guests) { + $fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => $guest); + + # for realms, don't show remote accounts with zero accesses, this could be confusing + next if ($guest =~ m{/} && $fnret && @{$fnret->value} == 0); + $guest_details{$guest} = $fnret ? scalar(@{$fnret->value}) : '?'; + push @guest_text, $guest . "[" . $guest_details{$guest} . "]"; + push @filtered_guests, $guest; + } + osh_info "Group " . $shortGroup . "'s Guests (with access to SOME of the group servers) are: " . colored(@filtered_guests ? join(" ", @guest_text) : '-', "red"); + + # deprecated in v2.18.00+ + $result_hash->{'full_members'} = \@members; + $result_hash->{'partial_members'} = \@filtered_guests; + + # /deprecated + + $result_hash->{'members'} = \@members; + $result_hash->{'guests'} = \@filtered_guests; + $result_hash->{'guests_accesses'} = \%guest_details; + + my @inactive; + foreach my $account (@members) { + if (OVH::Bastion::is_account_active(account => $account)->is_ko) { + push @inactive, $account; + } + } + if (@inactive) { + osh_info "For your information, the following accounts are inactive: " . colored(join(" ", @inactive), "blue"); + $result_hash->{'inactive'} = \@inactive; + } + + # policies + $fnret = OVH::Bastion::group_config(group => $group, key => 'mfa_required'); + if ($fnret && $fnret->value eq 'password') { + osh_warn "MFA Required: when connecting to servers of this group, users will be asked for an additional authentication factor (password)"; + } + elsif ($fnret && $fnret->value eq 'totp') { + osh_warn "MFA Required: when connecting to servers of this group, users will be asked for an additional authentication factor (TOTP)"; + } + elsif ($fnret && $fnret->value eq 'any') { + osh_warn "MFA Required: When connecting to servers of this group, users will be asked for an additional authentication factor"; + } + if ($fnret && $fnret->value ne 'none') { + $result_hash->{'mfa_required'} = $fnret->value; + } + + $fnret = OVH::Bastion::group_config(group => $group, key => 'guest_ttl_limit'); + if ($fnret) { + osh_warn "Guest TTL enforced: guest accesses must have a TTL with a maximum duration of " . OVH::Bastion::duration2human(seconds => $fnret->value)->value->{'duration'}; + $result_hash->{'guest_ttl_limit'} = $fnret->value; + } +} +else { + osh_info "You should ask him/her/them if you think you need access for your work tasks."; +} + +# get pubkeys with the proper from='' and show them + +$fnret = OVH::Bastion::get_bastion_ips(); +my $from; +if ($fnret) { + my @ips = @{$fnret->value}; + $from = 'from="' . join(',', @ips) . '"'; +} + +$fnret = OVH::Bastion::get_group_keys(group => $group); +if ($fnret and $from) { + osh_info ' '; + if (@{$fnret->value->{'sortedKeys'}} == 1) { + osh_info "The public key of this group is:"; + } + else { + osh_info "The public keys of this group are:"; + } + osh_info ' '; + foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + $key->{'prefix'} = $from; + OVH::Bastion::print_public_key(key => $key); + $result_hash->{'keys'}{$key->{'fingerprint'}} = $key; + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/open/groupInfo.json b/bin/plugin/open/groupInfo.json new file mode 100644 index 0000000..1041339 --- /dev/null +++ b/bin/plugin/open/groupInfo.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "groupInfo" , {"ac" : ["--group"]}, + "groupInfo --group" , {"ac" : [""]}, + "groupInfo --group \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/groupList b/bin/plugin/open/groupList new file mode 100755 index 0000000..b5fe34d --- /dev/null +++ b/bin/plugin/open/groupList @@ -0,0 +1,62 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($all); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "group list", + options => {'all' => \$all}, + helptext => <<'EOF', +List the groups available on this bastion + +Usage: --osh SCRIPT_NAME [--all] + + --all List all groups, even those to which you don't have access +EOF +); + +my $fnret; + +$fnret = OVH::Bastion::get_group_list(groupType => "key"); +$fnret or osh_exit $fnret; + +my $result_hash = {}; +foreach my $name (sort keys %{$fnret->value}) { + my @flags; + push @flags, 'owner' if OVH::Bastion::is_group_owner(group => $name); + push @flags, 'gatekeeper' if OVH::Bastion::is_group_gatekeeper(group => $name); + push @flags, 'aclkeeper' if OVH::Bastion::is_group_aclkeeper(group => $name); + push @flags, 'member' if OVH::Bastion::is_group_member(group => $name); + push @flags, 'guest' if OVH::Bastion::is_group_guest(group => $name); + if (@flags or $all) { + push @flags, 'no-access' if not @flags; + my $line = sprintf "%18s", $name; + $line .= sprintf " %14s", colored(grep({ $_ eq 'owner' } @flags) ? 'Owner' : '-', 'red'); + $line .= sprintf " %19s", colored(grep({ $_ eq 'gatekeeper' } @flags) ? 'GateKeeper' : '-', 'yellow'); + $line .= sprintf " %18s", colored(grep({ $_ eq 'aclkeeper' } @flags) ? 'ACLKeeper' : '-', 'magenta'); + $line .= sprintf " %15s", colored(grep({ $_ eq 'member' } @flags) ? 'Member' : '-', 'green'); + $line .= sprintf " %14s", colored(grep({ $_ eq 'guest' } @flags) ? 'Guest' : '-', 'cyan'); + osh_info $line; + $result_hash->{$name} = {flags => \@flags}; + } +} +if (keys %$result_hash) { + osh_info "\nIf you want to see all the groups, even the ones you don't have access to, use --all" if not $all; +} +else { + if (not $all) { + osh_ok R('OK_EMPTY', msg => "You are not in any group yet! You can use --all to see all groups"); + } + else { + osh_ok R('OK_EMPTY', msg => "No group has been created on this bastion yet!"); + } +} +osh_ok $result_hash; diff --git a/bin/plugin/open/groupList.json b/bin/plugin/open/groupList.json new file mode 100644 index 0000000..33d7f50 --- /dev/null +++ b/bin/plugin/open/groupList.json @@ -0,0 +1,6 @@ +{ + "interactive": [ + "groupList" , {"ac" : ["", "--all"]}, + "groupList --all" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/groupListPasswords b/bin/plugin/open/groupListPasswords new file mode 100755 index 0000000..f732c1c --- /dev/null +++ b/bin/plugin/open/groupListPasswords @@ -0,0 +1,60 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "list the egress passwords of the group", + options => { + "group=s" => \my $group, + }, + helptext => <<'EOF' +List the hashes and metadata of egress passwords of a group + +Usage: --osh SCRIPT_NAME --group GROUP + + --group GROUP Show the data for this group + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH +EOF +); + +# code +my $fnret; + +if (not $group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected a --group argument"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +$fnret = OVH::Bastion::is_group_member(account => $self, group => $shortGroup, superowner => 1); +$fnret or osh_exit('ERR_NOT_ALLOWED', "Sorry, you must be a member of group $shortGroup"); + +$fnret = OVH::Bastion::get_hashes_list(context => 'group', group => $shortGroup); +$fnret or osh_exit $fnret; + +foreach my $item (@{$fnret->value}) { + osh_info $item->{'description'}; + foreach my $hash (sort keys %{$item->{'hashes'}}) { + osh_info "... $hash: " . $item->{'hashes'}{$hash}; + } + osh_info "\n"; +} +if (not @{$fnret->value}) { + osh_info "This group doesn't have any egress password configured"; +} + +osh_ok($fnret); diff --git a/bin/plugin/open/groupListPasswords.json b/bin/plugin/open/groupListPasswords.json new file mode 100644 index 0000000..b717ef7 --- /dev/null +++ b/bin/plugin/open/groupListPasswords.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "groupListPasswords" , {"ac" : ["--group"]}, + "groupListPasswords --group" , {"ac" : [""]}, + "groupListPasswords --group \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/groupListServers b/bin/plugin/open/groupListServers new file mode 100755 index 0000000..aa6f3c8 --- /dev/null +++ b/bin/plugin/open/groupListServers @@ -0,0 +1,68 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($group, $reverse); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "list of servers pertaining to the group", + options => { + "group=s" => \$group, + "reverse-dns" => \$reverse, + }, + helptext => <<'EOF', +List the servers (IPs and IP blocks) pertaining to a group + +Usage: --osh SCRIPT_NAME --group GROUP [--reverse-dns] + + --group GROUP List the servers of this group + --reverse-dns Resolve and display the reverse DNS of each IP (SLOW!) +EOF +); + +my $fnret; + +if (!$group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter 'group'"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit $fnret; + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +if ( + !( + OVH::Bastion::is_group_member(group => $shortGroup, account => $self) + || OVH::Bastion::is_group_aclkeeper(group => $shortGroup, account => $self, superowner => 1) + || OVH::Bastion::is_auditor(account => $self) + ) + ) +{ + osh_exit( + R( + 'KO_ACCESS_DENIED', + msg => +"Sorry, you're neither a member or aclkeeper of group $shortGroup, you can't list the servers of this group.\nIf you think you should be able to, use groupInfo to get contact info." + ) + ); +} + +$fnret = OVH::Bastion::get_acl_way(way => 'group', group => $shortGroup); +$fnret or osh_exit $fnret; + +if (not @{$fnret->value}) { + osh_ok R('OK_EMPTY', msg => "This group is empty, if you are an aclkeeper of this group, you might want to add servers to it with groupAddServer"); +} + +OVH::Bastion::print_acls(acls => [{type => 'group', group => $shortGroup, acl => $fnret->value}], reverse => $reverse); +osh_ok($fnret->value); diff --git a/bin/plugin/open/groupListServers.json b/bin/plugin/open/groupListServers.json new file mode 100644 index 0000000..9bf4e57 --- /dev/null +++ b/bin/plugin/open/groupListServers.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "groupListServers" , {"ac" : ["--group"]}, + "groupListServers --group" , {"ac" : [""]}, + "groupListServers --group \\S+" , {"ac" : ["", "--reverse-dns"]}, + "groupListServers --group \\S+ --reverse-dns" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/help b/bin/plugin/open/help new file mode 100755 index 0000000..3f07ab9 --- /dev/null +++ b/bin/plugin/open/help @@ -0,0 +1,162 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "OSH help", + options => {}, + helptext => <<'EOF', +I'm So Meta, Even This Acronym + +Usage: --osh SCRIPT_NAME +EOF +); + +# +# code +# +my $fnret; + +my @knownPlugins = ( + 'MANAGE YOUR ACCOUNT' => [ + 'manage your ingress credentials (you->bastion)' => [ + qw{ selfListIngressKeys selfResetIngressKeys selfAddIngressKey selfDelIngressKey + selfGenerateProxyPassword selfMFASetupPassword selfMFAResetPassword selfMFASetupTOTP selfMFAResetTOTP } + ], + 'manage your egress credentials (bastion->server)' => [qw{ selfListEgressKeys selfGenerateEgressKey selfDelEgressKey selfGeneratePassword selfListPasswords }], + 'manage your accesses to servers' => [qw{ selfListAccesses selfAddPersonalAccess selfDelPersonalAccess selfForgetHostKey }], + 'manage your current sessions' => [qw{ lock unlock }], + 'review past sessions' => [qw{ selfListSessions selfPlaySession }], + 'other commands' => [qw{ selfModify }], + ], + 'MANAGE OTHER ACCOUNTS' => [ + 'manage bastion accounts' => [qw{ accountList accountCreate accountCreateOvh accountDelete accountUnexpire accountModify accountPIV }], + 'manage accounts ingress credentials (them->bastion)' => [qw{ accountListIngressKeys accountResetIngressKeys accountMFAResetPassword accountMFAResetTOTP }], + 'manage accounts egress credentials (bastion->server)' => [qw{ accountListEgressKeys accountGeneratePassword accountListPasswords }], + 'manage access to restricted commands' => [qw{ accountGrantCommand accountRevokeCommand accountInfo }], + 'manage another account accesses to servers' => [qw{ accountListAccesses accountAddPersonalAccess accountDelPersonalAccess whoHasAccessTo }], + 'review past sessions' => [qw{ accountListSessions globalListSessions }], + ], + 'MANAGE GROUPS' => [ + 'information and lifecycle' => [qw{ groupInfo groupListServers groupList groupCreate groupDelete }], + 'group owner commands' => [ + qw{ groupAddGatekeeper groupDelGatekeeper groupAddAclkeeper groupDelAclkeeper + groupAddOwner groupDelOwner groupTransmitOwnership groupGenerateEgressKey groupModify } + ], + 'egress passwords commands' => [qw{ groupListPasswords groupGeneratePassword groupDelPassword }], + 'gatekeeper commands to manage members' => [qw{ groupAddMember groupDelMember groupAddGuestAccess groupDelGuestAccess }], + 'gatekeeper commands to manage guests' => [qw{ groupListGuestAccesses groupAddGuestAccess groupDelGuestAccess }], + 'aclkeeper commands to manage group servers' => [qw{ groupAddServer groupDelServer }], + ], + 'BASTION ADMIN' => [ + 'other commands' => [qw{ adminSudo adminMaintenance }], + ], + 'MISC COMMANDS' => [ + 'basic commands' => [qw{ help info }], + 'utility commands' => [qw{ nc ping mtr alive clush scp batch }], + 'realm commands' => [qw{ realmList realmInfo realmCreate realmDelete }], + 'audit commands' => [qw{ rootListIngressKeys }], + 'other specific commands', + ], +); + +my %colorpanel = ( + 'open' => 'green', + 'restricted' => 'cyan', + 'group-aclkeeper' => 'yellow', + 'group-gatekeeper' => 'yellow', + 'group-owner' => 'magenta', + 'admin' => 'red', +); + +# create a hash with all the plugins listed above +my %alreadySeenPlugins; +my $i = 1; +while ($i < scalar @knownPlugins) { + my $j = 1; + while ($j < scalar @{$knownPlugins[$i]}) { + foreach (@{$knownPlugins[$i]->[$j]}) { + $alreadySeenPlugins{$_} = 1; + } + $j += 2; + } + $i += 2; +} + +# then get the real list of this bastion +$fnret = OVH::Bastion::get_plugin_list(); +$fnret or osh_exit $fnret; + +# and add plugins not listed above to the 'other specific commands' section +my @otherPlugins; +foreach my $plugin (sort keys %{$fnret->value}) { + next if exists $alreadySeenPlugins{$plugin}; + osh_debug("an unknown but valid command $plugin in " . $fnret->value->{$plugin}{'dir'}); + push @otherPlugins, $plugin; +} +$knownPlugins[-1][scalar(@{$knownPlugins[-1]})] = \@otherPlugins; + +$i = 0; +while ($i < scalar @knownPlugins) { + my $mainCategoryName = $knownPlugins[$i++]; + my $mainCategoryArray = $knownPlugins[$i++]; + my $mainCategoryNamePrinted = 0; + my $j = 0; + while ($j < scalar @$mainCategoryArray) { + my $subCategoryName = $mainCategoryArray->[$j++]; + my @plugins = @{$mainCategoryArray->[$j++]}; + + osh_debug("working on cat [$mainCategoryName // $subCategoryName] with " . (scalar @plugins) . " commands"); + osh_debug("plugins are: " . join('/', @plugins)); + + my @availableCommands; + my $curLen; + my $curIndex; + foreach my $cmd (@plugins) { + $fnret = OVH::Bastion::can_account_execute_plugin(account => $self, plugin => $cmd); + next unless $fnret; + if (($curLen + length($fnret->value->{'plugin'})) > 80) { + $curIndex++; + $curLen = 0; + } + $curLen += length($fnret->value->{'plugin'}); + push @{$availableCommands[$curIndex]}, colored($fnret->value->{'plugin'}, $colorpanel{$fnret->value->{'type'}}); + } + if (@availableCommands) { + if (not $mainCategoryNamePrinted) { + osh_info " > $mainCategoryName"; + $mainCategoryNamePrinted = 1; + } + osh_info " - $subCategoryName:"; + osh_info " " . join(' ', @$_) for @availableCommands; + } + } + osh_info ' '; +} + +osh_info "\nUse --help to get extra help when entering a command"; +my $bastionName = OVH::Bastion::config('bastionName'); +if ($bastionName) { + $bastionName = $bastionName->value; + osh_info "i.e. $bastionName --osh info --help"; +} +my $docURL = OVH::Bastion::config('documentationURL'); +if ($docURL && $docURL->value) { + osh_info "Documentation: " . $docURL->value; +} + +if (OVH::Bastion::config('readOnlySlaveMode')->value) { + osh_warn "\nNOTICE: This bastion is part of a cluster, and this instance is a read-only one (slave), " + . "so only read-only compliant commands are available. If you need to use write/modify commands, " + . "please do it on the master of the cluster instead."; +} + +osh_ok; diff --git a/bin/plugin/open/help.json b/bin/plugin/open/help.json new file mode 100644 index 0000000..f3fcbef --- /dev/null +++ b/bin/plugin/open/help.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "help" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/info b/bin/plugin/open/info new file mode 100755 index 0000000..8f8bccb --- /dev/null +++ b/bin/plugin/open/info @@ -0,0 +1,256 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Sys::Hostname (); +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($name); +OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "information", + options => {'name' => \$name}, + helptext => <<'EOF', +Displays some information about this bastion instance + +Usage: --osh SCRIPT_NAME +EOF +); + +my $fnret = OVH::Bastion::load_configuration(); +$fnret or osh_exit($fnret); +my $config = $fnret->value; + +my %ret; + +my $selfdisplay = $remoteself ? $remoteself : $self; +$ret{'account'} = $selfdisplay; +osh_info "You are " . colored($selfdisplay, 'green'); +if ($realm) { + osh_info "You are a citizen of a distant realm named " . colored($realm, 'green'); + $ret{'realm'} = $realm; +} +if (OVH::Bastion::is_auditor(account => $self)) { + $ret{'is_auditor'} = 1; + osh_info "You are a " . colored('bastion auditor!', 'green'); +} +if (OVH::Bastion::is_super_owner(account => $self)) { + $ret{'is_superowner'} = 1; + osh_info "Look at you, you are a " . colored('bastion superowner!', 'green'); +} +if (OVH::Bastion::is_admin(account => $self)) { + $ret{'is_admin'} = 1; + osh_info "Woosh, you are even a " . colored('bastion admin!', 'green'); +} + +if (!$realm) { + osh_info "\nYour alias to connect to this bastion is:"; + my $bastionName = OVH::Bastion::config('bastionName')->value(); + my $bastionCommand = OVH::Bastion::config('bastionCommand')->value(); + $bastionCommand =~ s/USER|ACCOUNT/$self/g; + $bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g; + my $hostname = Sys::Hostname::hostname(); + $bastionCommand =~ s/HOSTNAME/$hostname/g; + osh_info colored("alias $bastionName='$bastionCommand'", "magenta"); + $ret{'bastion_alias_command'} = $bastionCommand; + + if (OVH::Bastion::config('moshAllowed')->value) { + osh_info "Your alias to connect to this bastion with MOSH is:"; + my ($cacheDestination) = $bastionCommand =~ m{(\S+@\S+)}; + $bastionCommand =~ s{\Q$cacheDestination\E}{}; + $bastionCommand =~ s{\s+--\s+$}{}; + $bastionCommand =~ s{ +}{ }g; + osh_info colored("alias ${bastionName}m='mosh --ssh=\"$bastionCommand\" $cacheDestination -- '", "magenta"); + $ret{'bastion_alias_command_mosh'} = "mosh --ssh=\"$bastionCommand\" $cacheDestination -- "; + } +} + +osh_info "\nMulti-Factor Authentication (MFA) on your account:"; +$ret{'mfa_password_required'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) ? 1 : 0; +$ret{'mfa_password_bypass'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) ? 1 : 0; +$ret{'mfa_password_configured'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) ? 1 : 0; +osh_info "- Additional password authentication is " . ($ret{'mfa_password_required'} ? colored('required', 'green') : colored('not required', 'blue')); +osh_info "- Additional password authentication bypass is " . ($ret{'mfa_password_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')); +osh_info "- Additional password authentication is " . ($ret{'mfa_password_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); + +$ret{'mfa_totp_required'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) ? 1 : 0; +$ret{'mfa_totp_bypass'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) ? 1 : 0; +$ret{'mfa_totp_configured'} = OVH::Bastion::is_user_in_group(user => $self, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) ? 1 : 0; +osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_required'} ? colored('required', 'green') : colored('not required', 'blue')); +osh_info "- Additional TOTP authentication bypass is " . ($ret{'mfa_totp_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')); +osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); + +$fnret = OVH::Bastion::sys_getpasswordinfo(user => $self); +if ($fnret) { + $ret{"password_$_"} = $fnret->value->{$_} for (keys %{$fnret->value}); + osh_info "\nAccount PAM UNIX password information (used for password MFA):"; + osh_info sprintf("- Password is %s", + $ret{'password_password'} eq 'locked' + ? colored('unused', 'blue') + : ($ret{'password_password'} eq 'set' ? colored('set', 'green') : colored($ret{'password_password'}, 'red'))); + osh_info "- Password was last changed on " . colored($ret{'password_date_changed'}, 'magenta'); + if ($ret{'password_max_days'} == -1) { + osh_info "- Password will never expire"; + } + else { + osh_info "- Password must be changed every " . colored($ret{'password_max_days'}, 'magenta') . " days at least"; + osh_info "- A warning is displayed " . colored($ret{'password_warn_days'}, 'magenta') . " days before expiration"; + } + if ($ret{'password_min_days'} != 0) { + osh_info "- The minimum time between two password changes is " . $ret{'password_min_days'} . " days"; + } + if ($ret{'password_max_days'} != -1) { + if ($ret{'password_inactive_days'} != -1) { + osh_info "- Account will be disabled " . colored($ret{'password_inactive_days'}, 'magenta') . " days after password expiration"; + } + else { + osh_info "- Account will " . colored('not', 'magenta') . " be disabled after password expiration"; + } + } +} + +osh_info sprintf("\nI am %s, aka %s", colored(Sys::Hostname::hostname(), 'green'), colored($config->{'bastionName'}, 'green')); +$ret{'hostname'} = Sys::Hostname::hostname(); +$ret{'bastion_name'} = $config->{'bastionName'}; + +$fnret = OVH::Bastion::get_account_list(); +my $nbaccounts = $fnret ? keys %{$fnret->value} : '?'; +$fnret = OVH::Bastion::get_group_list(groupType => 'key'); +my $nbgroups = $fnret ? keys %{$fnret->value} : '?'; +osh_info "I have " . colored($nbaccounts, 'green') . " registered accounts and " . colored($nbgroups, 'green') . " groups"; +$ret{'registered_accounts'} = $nbaccounts; +$ret{'registered_groups'} = $nbgroups; + +if ($config->{'readOnlySlaveMode'}) { + osh_info "I am a " . colored("SLAVE", "cyan") . ", which means modifications must be made through my master"; +} +else { + osh_info "I am a " . colored("MASTER", "cyan") . ", which means I accept modifications"; +} +$ret{'slave_mode'} = $config->{'readOnlySlaveMode'}; + +my @allowedNets = @{$config->{'allowedNetworks'}}; +osh_info "The networks I'm able to connect you to on the egress side are: " . colored(@allowedNets ? join(", ", @allowedNets) : "all", "magenta"); +$ret{'allowed_networks_list'} = \@allowedNets; + +my @forbiddenNets = @{$config->{'forbiddenNetworks'}}; +osh_info "The networks that are explicitely forbidden on the egress side are: " . colored(@forbiddenNets ? join(", ", @forbiddenNets) : "none", "magenta"); +$ret{'forbidden_networks_list'} = \@forbiddenNets; + +$fnret = OVH::Bastion::get_bastion_ips(); +if ($fnret) { + my @ips = grep { !/^127\./ } @{$fnret->value}; + if (@ips > 1) { + osh_info "My egress connection IPs to remote servers are " . colored(join(", ", @ips), "magenta"); + osh_info "...this includes the IPs of my potential siblings, don't forget to whitelist those in your firewalls!"; + } + else { + osh_info "My egress connection IP to remote servers is " . colored(join(", ", @ips), "magenta"); + osh_info "...don't forget to whitelist me in your firewalls!"; + } + $ret{'egress_ip_list'} = \@ips; +} + +osh_info "\nThe following policy applies on this bastion:"; +osh_info "- The interactive mode (-i) is " . ($config->{'interactiveModeAllowed'} ? colored('ENABLED', 'green') : colored('DISABLED', 'red')); +$ret{'interactive_mode_allowed'} = $config->{'interactiveModeAllowed'}; +osh_info "- The support of mosh is " . ($config->{'moshAllowed'} ? colored('ENABLED', 'green') : colored('DISABLED', 'red')); +$ret{'mosh_allowed'} = $config->{'moshAllowed'}; +if ($config->{'accountMaxInactiveDays'}) { + osh_info "- Account expiration is " . colored('ENABLED', 'green') . ", with an expiration time of " . colored($config->{'accountMaxInactiveDays'}, 'magenta') . " days"; +} +else { + osh_info "- Account expiration is " . colored('DISABLED', 'red'); +} +$ret{'account_expiration_days'} = $config->{'accountMaxInactiveDays'}; + +if ($config->{'idleLockTimeout'}) { + osh_info "- Keyboard input idle time for session locking is " + . colored('ENABLED', 'green') + . ", kicking in after " + . colored($config->{'idleLockTimeout'}, 'magenta') + . " seconds"; +} +else { + osh_info "- Keyboard input idle time for session locking is " . colored('DISABLED', 'red'); +} +$ret{'idle_lock_timeout'} = $config->{'idleLockTimeout'}; + +if ($config->{'idleKillTimeout'}) { + osh_info "- Keyboard input idle time for session killing is " + . colored('ENABLED', 'green') + . ", kicking in after " + . colored($config->{'idleKillTimeout'}, 'magenta') + . " seconds"; +} +else { + osh_info "- Keyboard input idle time for session killing is " . colored('DISABLED', 'red'); +} +$ret{'idle_kill_timeout'} = $config->{'idleKillTimeout'}; + +$fnret = OVH::Bastion::get_from_for_user_key(); +if ($fnret && $fnret->value->{'from'}) { + osh_info "- The forced \"from\" prepend on ingress keys is " . colored('ENABLED', 'green') . ", with the following value: " . colored($fnret->value->{'from'}, 'magenta'); + $ret{'ingress_keys_from_ip_list'} = $fnret->value->{'ipList'}; +} +else { + osh_info "- The forced \"from\" prepend on ingress keys is " . colored('DISABLED', 'red'); + $ret{'ingress_keys_from_ip_list'} = []; +} + +foreach my $way (qw{ ingress egress }) { + $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => $way); + if ($fnret) { + osh_info "- The following algorithms are allowed for $way SSH keys: " . colored(join(', ', @{$fnret->value}), 'magenta'); + $ret{"${way}_ssh_key_algorithms"} = $fnret->value; + } + if (grep { $_ eq 'rsa' } @{$fnret->value}) { + osh_info "- The RSA key size for $way SSH keys must be between " + . colored($config->{"minimum" . ucfirst($way) . "RsaKeySize"}, "magenta") . " and " + . colored($config->{"maximum" . ucfirst($way) . "RsaKeySize"}, "magenta") . " bits"; + $ret{"${way}_rsa_min_size"} = $config->{"minimum" . ucfirst($way) . "RsaKeySize"}; + $ret{"${way}_rsa_max_size"} = $config->{"maximum" . ucfirst($way) . "RsaKeySize"}; + } +} +osh_info "- The Multi-Factor Authentication (MFA) policy is " . colored(uc($config->{'accountMFAPolicy'}), $config->{'accountMFAPolicy'} eq 'disabled' ? 'red' : 'green'); + +if (OVH::Bastion::is_admin(account => $self)) { + osh_info "\nAs you are a bastion admin, more information follows:"; + $fnret = OVH::Bastion::sysinfo(); + if ($fnret and $fnret->value) { + osh_info "We're running under " . $fnret->value->{'system'} . " " . $fnret->value->{'release'}; + $ret{'os_system'} = $fnret->value->{'system'}; + $ret{'os_release'} = $fnret->value->{'release'}; + } + + $fnret = OVH::Bastion::execute(cmd => ['uptime']); + if ($fnret) { + $fnret->value->{'stdout'}->[0] =~ s/^\s+//; + osh_info $fnret->value->{'stdout'}->[0]; + $ret{'uptime'} = $fnret->value->{'stdout'}->[0]; + } + + $fnret = OVH::Bastion::execute(cmd => ['free', '-h']); + if ($fnret) { + osh_info "Memory info:"; + osh_info join("\n", @{$fnret->value->{'stdout'}}); + } +} + +if (-x "/usr/games/fortune") { + my @command = qw{ /usr/games/fortune bofh-excuses }; + $fnret = OVH::Bastion::execute(cmd => \@command); + if ($fnret) { + my $fortune = join("\n", grep { $_ } @{$fnret->value->{'stdout'}}); + osh_info "\nHere is your excuse for anything not working today:"; + osh_info $fortune; + $ret{'fortune'} = $fortune; + } +} + +osh_ok \%ret; diff --git a/bin/plugin/open/info.json b/bin/plugin/open/info.json new file mode 100644 index 0000000..7fde235 --- /dev/null +++ b/bin/plugin/open/info.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "info" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/lock b/bin/plugin/open/lock new file mode 100755 index 0000000..b73e9b1 --- /dev/null +++ b/bin/plugin/open/lock @@ -0,0 +1,42 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "lock all your current sessions", + options => { + 'report' => \my $report, + }, + helptext => <<'EOF', +Manually lock all your current sessions + +Usage: --osh SCRIPT_NAME +EOF +); + +# code +my $fnret; +my @command; + +@command = qw{ pkill -c --uid }; +push @command, $self; +push @command, qw{ -URG ttyrec }; + +osh_info "Locking all your sessions..."; + +$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 0, noisy_stderr => 1); +$fnret or osh_exit $fnret; + +if ($fnret->value && $fnret->value->{'stdout'}) { + my $nb = $fnret->value->{'stdout'}->[0] / 2; + osh_info "Sent lock signal to $nb session" . ($nb == 1 ? '' : 's'); +} + +osh_ok {}; diff --git a/bin/plugin/open/lock.json b/bin/plugin/open/lock.json new file mode 100644 index 0000000..9678f8e --- /dev/null +++ b/bin/plugin/open/lock.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "lock" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/mtr b/bin/plugin/open/mtr new file mode 100755 index 0000000..5128214 --- /dev/null +++ b/bin/plugin/open/mtr @@ -0,0 +1,49 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "traceroute", + options => { + 'report' => \my $report, + }, + helptext => <<'EOF', +Runs the mtr tool to traceroute a host + +Usage: --osh SCRIPT_NAME [--host] HOST [--report] + + --report Don't run mtr interactively, output a text report once done +EOF +); + +# be nice and try to guessify a host as first param +# if user said --osh mtr mymachine.example.org +if (not $host and not $ip and ref $remainingOptions eq 'ARRAY' and @$remainingOptions == 1 and $remainingOptions->[0] =~ /^([a-zA-Z0-9][a-zA-Z0-9.-]+)$/) { + $host = $remainingOptions->[0]; +} + +# code +my $fnret; + +if (not $host) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing required host parameter"; +} + +my @command = qw{ mtr --show-ips --aslookup -n }; +push @command, ($report ? '--report' : '--curses'); +push @command, $host; + +osh_info "Tracing $host..."; + +$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1); +$fnret or osh_exit $fnret; + +osh_ok {}; diff --git a/bin/plugin/open/mtr.json b/bin/plugin/open/mtr.json new file mode 100644 index 0000000..c3e04fe --- /dev/null +++ b/bin/plugin/open/mtr.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "mtr" , {"ac" : ["--host"]}, + "mtr --host" , {"pr" : [""]}, + "mtr --host \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/nc b/bin/plugin/open/nc new file mode 100755 index 0000000..52fc260 --- /dev/null +++ b/bin/plugin/open/nc @@ -0,0 +1,89 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "netcat -z", + options => {"w=i" => \my $timeout}, + helptext => <<'EOF', +Check whether a remote TCP port is open + +Usage: --osh SCRIPT_NAME [--host] HOST [--port] PORT [-w TIMEOUT] + + --host HOST Host or IP to attempt to connect to + --port PORT TCP port to attempt to connect to + -w SECONDS Timeout in seconds (default: 3) +EOF +); + +# be nice and try to guessify a host as first param and port as second param +# if user said --osh nc mymachine.example.org 22 +if ( not $host + and not $ip + and not $port + and ref $remainingOptions eq 'ARRAY' + and @$remainingOptions == 2 + and $remainingOptions->[0] =~ /^[a-zA-Z0-9][a-zA-Z0-9.-]{1,}$/ + and $remainingOptions->[1] =~ /^\d+$/) +{ + ($host, $port) = @$remainingOptions; +} + +# +# code +# +my $fnret; + +if (not $host) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing required host parameter"; +} + +if (not $port) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing required port parameter"; +} + +$fnret = OVH::Bastion::is_valid_port(port => $port); +if (!$fnret) { + help(); + osh_exit $fnret; +} + +my @command = qw{ nc -v -z -w }; +push @command, ($timeout and $timeout > 0 and $timeout <= 3600) ? $timeout : 3; +push @command, $host; +push @command, $port; + +osh_info "Checking wether TCP port $port of $host is reachable..."; + +$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1); +$fnret or osh_exit $fnret; + +# try to guess what happened +my $answer = 'unknown'; +if ($fnret->value->{'sysret'} == 0) { + $answer = 'open'; +} +elsif (grep { /timeout|timed out/i } @{$fnret->value->{'stderr'}}) { + $answer = 'timeout'; +} +elsif (grep { /refused/i } @{$fnret->value->{'stderr'}}) { + $answer = 'closed'; +} + +osh_ok { + sysret => $fnret->value->{'sysret'} + 0, + output => $fnret->value->{'stderr'}, + host => $host, + port => $port + 0, + result => $answer +}; + diff --git a/bin/plugin/open/nc.json b/bin/plugin/open/nc.json new file mode 100644 index 0000000..d8aee53 --- /dev/null +++ b/bin/plugin/open/nc.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "nc" , {"ac" : ["--host"]}, + "nc --host" , {"pr" : [""]}, + "nc --host \\S+" , {"ac" : ["--port"]}, + "nc --host \\S+ --port \\d+" , {"ac" : ["", "--timeout"]}, + "nc --host \\S+ --port \\d+ --timeout" , {"pr" : [""]}, + "nc --host \\S+ --port \\d+ --timeout \\d+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/ping b/bin/plugin/open/ping new file mode 100755 index 0000000..38c8760 --- /dev/null +++ b/bin/plugin/open/ping @@ -0,0 +1,89 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "ping", + options => { + "c=i" => \my $count, + "s=i" => \my $packetsize, + "t=i" => \my $ttl, + "w=i" => \my $deadline, + }, + helptext => <<'EOF', +Ping a remote host from the bastion + +Usage: --osh SCRIPT_NAME [--host HOST] [-c COUNT] [-s PKTSZ] [-t TTL] [-w TIMEOUT] + + --host HOST Remote host to ping + -c COUNT Number of pings to send (default: infinite) + -t TTL TTL to set in the ICMP packet (default: OS dependent) + -w TIMEOUT Exit unconditionally after this amount of seconds +EOF +); + +# be nice and try to guessify a host as first param +# if user said --osh ping mymachine.example.org +if (not $host and not $ip and ref $remainingOptions eq 'ARRAY' and @$remainingOptions == 1 and $remainingOptions->[0] =~ /^([a-zA-Z0-9][a-zA-Z0-9.-]{1,})$/) { + $host = $remainingOptions->[0]; +} + +# +# code +# +my $fnret; + +if (not $host) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing required host parameter"; +} + +my @command = qw{ ping }; +if ($count and $count > 0) { + push @command, ('-c', $count); +} +if ($packetsize and $packetsize > 0 and $packetsize < 10000) { + push @command, ('-s', $packetsize); +} +if ($ttl and $ttl > 0 and $ttl < 256) { + push @command, ('-t', $ttl); +} +if ($deadline and $deadline > 0 and $deadline <= 3600) { + push @command, (OVH::Bastion::is_freebsd() ? '-t' : '-w', $deadline); +} +push @command, $host; + +osh_info "Pinging $host..."; + +$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1); +$fnret or osh_exit $fnret; + +my $result_hash = {sysret => $fnret->value->{'sysret'}, host => $host}; +foreach (@{$fnret->value->{'stdout'}}) { + + # '2 packets transmitted, 2 received, 0% packet loss, time 999ms', + # 'rtt min/avg/max/mdev = 0.018/0.038/0.059/0.021 ms' + + # '2 packets transmitted, 2 packets received, 0.0% packet loss', + # 'round-trip min/avg/max/stddev = 0.057/0.073/0.089/0.000 ms', + + ## no critic (ProhibitUnusedCapture) # false positive + if ( +m{(?\d+) packets? transmitted, (?\d+)( packets)? received, (?\d+)(\.\d+)?% packet loss(, time (?\d+))?} + ) + { + @$result_hash{keys %+} = values %+; + } + elsif (m{min/avg/max/(std|m)dev = (?[0-9.]+)/(?[0-9.]+)/(?[0-9.]+)/(?[0-9.]+)}) { + @$result_hash{keys %+} = values %+; + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/open/ping.json b/bin/plugin/open/ping.json new file mode 100644 index 0000000..20ce97d --- /dev/null +++ b/bin/plugin/open/ping.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "ping" , {"ac" : ["--host"]}, + "ping --host" , {"pr" : [""]}, + "ping --host \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/scp b/bin/plugin/open/scp new file mode 100755 index 0000000..cc31f6e --- /dev/null +++ b/bin/plugin/open/scp @@ -0,0 +1,203 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use MIME::Base64; +use IO::Compress::Gzip qw{ gzip }; +use Sys::Hostname (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT ); + +my ($scpCmd); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => undef, + options => {'scp-cmd=s' => \$scpCmd}, + help => \&help, +); + +sub help { + osh_header("scp"); + my $config = OVH::Bastion::load_configuration(); + $config or osh_exit $config; + my $bastionCommand = $config->value->{'bastionCommand'}; + my $bastionName = $config->value->{'bastionName'}; + $bastionCommand =~ s/USER|ACCOUNT/$self/g; + $bastionCommand =~ s/CACHENAME|BASTIONNAME/$bastionName/g; + my $hostname = Sys::Hostname::hostname(); + $bastionCommand =~ s/HOSTNAME/$hostname/g; + + # for scp, if the bastionCommand contains -t, we need to get rid of it + $bastionCommand =~ s/ -t( |$)/$1/; + + # same thing for -- + $bastionCommand =~ s/ --/ /; + my $script = <<"EOF"; +#! /bin/sh +#scpwrapper v1.0 +while ! [ "\$1" = "--" ] ; do + if [ "\$1" = "-l" ] ; then + remoteuser="--user \$2" + shift 2 + elif [ "\$1" = "-p" ] ; then + remoteport="--port \$2" + shift 2 + else + sshcmdline="\$sshcmdline \$1" + shift + fi +done +host="\$2" +scpcmd=`echo "\$3" | sed -e 's/#/##/g;s/ /#/g'` +exec $bastionCommand -T \$sshcmdline -- \$remoteuser \$remoteport --host \$host --osh scp --scp-cmd "\$scpcmd" +EOF + my $compressed = ''; + gzip \$script => \$compressed; + my $base64 = encode_base64($compressed); + chomp $base64; + osh_info <<"EOF"; +Description: + Transfers files to/from a host through the bastion + +Usage: + To use scp through the bastion, you need a helper script to use with +your scp client. It'll be specific to your account, don't share it with +others! To download your customized script, copy/paste this command: +EOF + print "\necho \"$base64\"|base64 -d|gunzip -c > ~/scp_$bastionName && chmod +x ~/scp_$bastionName\n\n"; + osh_info <<"EOF"; +To use scp through this bastion, add `-S ~/scp_$bastionName` to your regular scp command. +For example, to upload a file: +\$ scp -S ~/scp_$bastionName localfile login\@server:/dest/folder/ + +Or to recursively download a folder contents: +\$ scp -S ~/scp_$bastionName -r login\@server:/src/folder/ /tmp/ + +Please note that you need to be granted for uploading or downloading files +with scp to/from the remote host, in addition to having the right to SSH to it. +For a group, the right should be added with --scpup/--scpdown of the groupAddServer command. +For a personal access, the right should be added with --scpup/--scpdown of the selfAddPersonalAccess command. +EOF + osh_ok({script => $base64, "content-encoding" => 'base64-gzip'}); + return 0; +} + +# +# code +# +my $fnret; + +if (not $host) { + help(); + osh_exit; +} + +my $machine = $ip; +$machine = "$user\@$ip" if $user; +$port ||= 22; # scp uses 22 if not specified, so we need to test access to that port and not any port (aka undef) +$user ||= $self; # same for user +$machine .= ":$port"; + +# decode the passed scp command + +my $decoded = $scpCmd; +$decoded =~ s/(? $self, user => $user, ipfrom => $ENV{'OSH_IP_FROM'}, ip => $ip, port => $port, wantKeys => 1); +if (not $fnret) { + my $msg = "Sorry, but you don't seem to have access to $machine"; + print STDERR ">>>" . $msg . "\n"; + osh_exit 'ERR_ACCESS_DENIED', $msg; +} + +# get the keys we would try +foreach my $access (@{$fnret->value || []}) { + foreach my $key (@{$access->{'sortedKeys'} || []}) { + my $keyfile = $access->{'keys'}{$key}{'fullpath'}; + $keys{$keyfile}++ if -r $keyfile; + osh_debug("Checking access 1/2 keyfile: $keyfile"); + } +} + +osh_debug("Checking access 2/2 of $self to $userToCheck of $machine..."); +$fnret = OVH::Bastion::is_access_granted(account => $self, user => $userToCheck, ipfrom => $ENV{'OSH_IP_FROM'}, ip => $ip, port => $port, exactUserMatch => 1, wantKeys => 1); +if (not $fnret) { + my $msg = "Sorry, but even if you have ssh access to $machine, you still need to be granted specifically for scp"; + print STDERR ">>>" . $msg . "\n"; + osh_exit 'ERR_ACCESS_DENIED', $msg; +} + +# get the keys we would try too +foreach my $access (@{$fnret->value || []}) { + foreach my $key (@{$access->{'sortedKeys'} || []}) { + my $keyfile = $access->{'keys'}{$key}{'fullpath'}; + $keys{$keyfile}++ if -r $keyfile; + osh_debug("Checking access 2/2 keyfile: $keyfile"); + } +} + +# now build the command + +my @cmd = qw{ ssh -x -oForwardAgent=no -oPermitLocalCommand=no -oClearAllForwardings=yes }; +push @cmd, ('-p', $port) if $port; +push @cmd, ('-l', $user) if $user; + +my $atleastonekey = 0; +foreach my $keyfile (keys %keys) { + + # only use the key if it has been seen in both allow_deny() calls, this is to avoid + # a security bypass where a user would have group access to a server, but not to the + # !scpupload special user, and we would add himself this access through selfAddPrivateAccess. + # in that case both allow_deny would return OK, but with different keys. + # we'll only use the keys that matched BOTH calls. + next unless $keys{$keyfile} == 2; + push @cmd, ('-i', $keyfile); + $atleastonekey = 1; +} + +if (not $atleastonekey) { + osh_exit('KO_ACCESS_DENIED', +"Sorry, you seem to have access through ssh and through scp but by different and distinct means (distinct keys). The intersection between your rights for ssh and for scp needs to be at least one." + ); +} + +push @cmd, "--", $ip, $decoded; + +=cut attempt to be more secure than even standard scp, but don't bother ... +my ($additionalParams,$remoteFile) = ($2,$3); + +push @cmd, 'scp'; +if (defined $additionalParams) +{ + push @cmd, split(/ /, $additionalParams); +} +push @cmd, '-t', "\Q$remoteFile\E"; +=cut + +print STDERR ">>> Hello $self, transferring your file through the bastion " . ($userToCheck eq '!scpupload' ? 'to' : 'from') . " $machine...\n"; + +#print STDERR join('^', @cmd)."\n"; +$fnret = OVH::Bastion::execute(cmd => \@cmd, expects_stdin => 1, is_binary => 1); +if ($fnret->err ne 'OK') { + print STDERR ">>> Error launching transfer: " . $fnret->msg . "\n"; + exit OVH::Bastion::EXIT_PLUGIN_ERROR; +} +print STDERR ">>> Done, " . $fnret->value->{'bytesnb'}{'stdin'} . " bytes uploaded, " . $fnret->value->{'bytesnb'}{'stdout'} . " bytes downloaded.\n"; +if ($fnret->value->{'sysret'} != 0) { + print STDERR ">>> On bastion side, scp exited with return code " . $fnret->value->{'sysret'} . ".\n"; +} +exit OVH::Bastion::EXIT_OK; + diff --git a/bin/plugin/open/scp.json b/bin/plugin/open/scp.json new file mode 100644 index 0000000..f0ea237 --- /dev/null +++ b/bin/plugin/open/scp.json @@ -0,0 +1,3 @@ +{ + "execution_mode": "binary" +} diff --git a/bin/plugin/open/selfAddIngressKey b/bin/plugin/open/selfAddIngressKey new file mode 100755 index 0000000..728e855 --- /dev/null +++ b/bin/plugin/open/selfAddIngressKey @@ -0,0 +1,122 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($pubKey); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "add a new public key to your account", + options => { + "pubKey=s", \$pubKey, # deprecated name, keep it to not break scripts or people + "public-key=s", \$pubKey, + }, + helptext => <<'EOF', +Add a new ingress public key to your account + +Usage: --osh SCRIPT_NAME [--public-key '"ssh key text"'] + + --public-key KEY Your new ingress public SSH key to deposit on the bastion, use double-quoting if your're under a shell. + +If no option is specified, you'll be prompted interactively. +EOF +); + +# ugly hack for space-enabled parameter +if (ref $remainingOptions eq 'ARRAY' and @$remainingOptions) { + $pubKey .= " " . join(" ", @$remainingOptions); +} + +# +# code +# +my $fnret; + +$fnret = OVH::Bastion::account_config(account => $self, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY); +if ($fnret && $fnret->value eq 'yes') { + osh_exit R('ERR_ACCESS_DENIED', msg => "PIV-only policy is enabled for your account, you can't add new keys this way"); +} + +my $allowedKeyFile = $HOME . '/.ssh/authorized_keys2'; + +if (not defined $pubKey) { + $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'ingress'); + $fnret or osh_exit $fnret; + my @algoList = @{$fnret->value}; + my $algos = join(' ', @algoList); + osh_info "Please paste the SSH key you want to add. This bastion supports the following algorithms:\n"; + if (grep { 'ed25519' eq $_ } @algoList) { + osh_info "ED25519: strongness[#####] speed[#####], use `ssh-keygen -t ed25519' to generate one"; + } + if (grep { 'ecdsa' eq $_ } @algoList) { + osh_info "ECDSA : strongness[####.] speed[#####], use `ssh-keygen -t ecdsa -b 521' to generate one"; + } + if (grep { 'rsa' eq $_ } @algoList) { + osh_info "RSA : strongness[###..] speed[#....], use `ssh-keygen -t rsa -b 4096' to generate one"; + } + osh_info "\nIn any case, don't save it without a passphrase."; + if (OVH::Bastion::config('ingressKeysFromAllowOverride')->value) { + osh_info 'You can prepend your key with a from="IP1,IP2,..." as this bastion policy allows ingress keys "from" override by users'; + } + else { + osh_info 'Any from="IP1,IP2,..." you include will be ignored, as this bastion policy refuses ingress keys "from" override by users'; + } + $pubKey = ; +} + +$fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress'); +if (!$fnret) { + + # maybe we decoded the key but for some reason we don't want/can't add it + # in that case, return the data of the key in the same format as when this + # call works (see last line with osh_ok) + $fnret->{'value'} = {key => $fnret->value} if $fnret->value; + osh_exit $fnret; +} +my $key = $fnret->value; + +if (checkExistKey($key->{'base64'})) { + osh_exit R('KO_DUPLICATE_KEY', msg => "This public key already exists on your account!", value => {key => $key}); +} + +$fnret = OVH::Bastion::get_from_for_user_key(userProvidedIpList => $key->{'fromList'}, key => $key); +$fnret or osh_exit $fnret; + +if (open(my $fh_keyfile, '>>', $allowedKeyFile)) { + print $fh_keyfile $key->{'line'} . "\n"; + close($fh_keyfile); +} +else { + osh_exit 'ERR_CANNOT_OPEN_FILE', "Error while trying to open file $allowedKeyFile for write ($!)"; +} +osh_info "Public key successfully added"; +if (ref $key->{'fromList'} eq 'ARRAY' && @{$key->{'fromList'}}) { + osh_info "You will only be able to connect from: " . join(', ', @{$key->{'fromList'}}); +} + +sub checkExistKey { + + # only pass the base64 part of the key here (returned by get_ssh_pub_key_info->{'base64'}) + my $pubKeyB64 = shift; + + open(my $fh_keys, '<', $allowedKeyFile) || die("can't read the $allowedKeyFile file!\n"); + while (my $currentLine = <$fh_keys>) { + chomp $currentLine; + next if ($currentLine =~ /^\s*#/); + my $parsedResult = OVH::Bastion::get_ssh_pub_key_info(pubKey => $currentLine, way => "ingress"); + if ($parsedResult && $parsedResult->value->{'base64'} eq $pubKeyB64) { + close($fh_keys); + return $currentLine; + } + } + close($fh_keys); + return 0; +} + +$key->{'from_list'} = delete $key->{'fromList'}; # for json display +osh_ok {connect_only_from => $key->{'from_list'}, key => $key}; diff --git a/bin/plugin/open/selfAddIngressKey.json b/bin/plugin/open/selfAddIngressKey.json new file mode 100644 index 0000000..14f1eb5 --- /dev/null +++ b/bin/plugin/open/selfAddIngressKey.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "selfAddIngressKey" , {"ac" : ["--public-key \"",""]}, + "selfAddIngressKey --public-key \"" , {"pr" : [""]}, + "selfAddIngressKey --public-key \"[^\"]+" , {"ac" : ["\""]}, + "selfAddIngressKey --public-key \"[^\"]+\"" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/open/selfDelIngressKey b/bin/plugin/open/selfDelIngressKey new file mode 100755 index 0000000..41d5b0a --- /dev/null +++ b/bin/plugin/open/selfDelIngressKey @@ -0,0 +1,113 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($lineNumberToDelete, $fingerprintToDelete); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "Here are the public keys that allow you to connect to the bastion", + options => { + "line-number-to-delete|l=i" => \$lineNumberToDelete, + "fingerprint-to-delete|f=s" => \$fingerprintToDelete, + }, + helptext => <<"EOF", +Remove an ingress public key from your account + +Usage: --osh SCRIPT_NAME [--line-number-to-delete|-l NB] [--fingerprint-to-delete|-f FP] + + -l, --line-number-to-delete NB Directly specify the line number to delete (CAUTION!), you can get the line numbers with selfListIngressKeys + -f, --fingerprint-to-delete FP Directly specify the fingerprint of the key to delete (CAUTION!) + +If none of these options are specified, you'll be prompted interactively. +EOF +); + +# +# code +# +my $fnret; + +if ($fingerprintToDelete and defined $lineNumberToDelete) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You can't specify a line to delete AND a fingerprint to delete at the same time"; +} + +my $allowedKeyFile = "$HOME/.ssh/authorized_keys2"; +$fnret = OVH::Bastion::get_authorized_keys_from_file(file => $allowedKeyFile, includeInvalid => 1); +$fnret or osh_exit $fnret; + +my %allowedLines; +my %allowedFingerprints; +my @validKeys; + +foreach my $key (@{$fnret->value || []}) { + OVH::Bastion::print_public_key(key => $key, id => $key->{'index'}, err => $key->{'err'}); + $allowedLines{$key->{'index'}} = 1; + $allowedFingerprints{$key->{'fingerprint'}} = $key->{'index'} if (OVH::Bastion::is_valid_fingerprint(fingerprint => $key->{'fingerprint'})); + push @validKeys, $key->{'index'} if $key->{'err'} eq 'OK'; +} + +# Do we have anything to delete ? +if (@validKeys == 0) { + osh_exit 'ERR_NO_KEY', "You have no key to delete (wait, how did you connect in the first place?!)"; +} +elsif (not defined $lineNumberToDelete and not defined $fingerprintToDelete) { + osh_info "Type the key ID you want to delete then press ENTER (" . (join(',', sort { $a <=> $b } keys %allowedLines)) . "):"; + $lineNumberToDelete = ; + chomp $lineNumberToDelete; +} + +if (defined $fingerprintToDelete) { + if (not exists($allowedFingerprints{$fingerprintToDelete})) { + osh_exit 'ERR_NO_MATCH', "Couldn't find any key matching this fingerprint"; + } + $lineNumberToDelete = $allowedFingerprints{$fingerprintToDelete}; +} + +# here, either lineNumberToDelete has been specified or we just got it from STDIN + +if (defined $lineNumberToDelete) { + if ($lineNumberToDelete =~ /^(\d+)$/) { + $lineNumberToDelete = $1; # untaint + } + else { + osh_exit 'ERR_INVALID_PARAMETER', "Invalid number specified"; + } + + if (not exists $allowedLines{$lineNumberToDelete}) { + osh_exit 'ERR_INVALID_ID', "Bad key ID"; + } + if (@validKeys == 1 && $validKeys[0] == $lineNumberToDelete) { + osh_exit 'ERR_ONLY_ONE_KEY', "You can't delete the only valid key you have!"; + } + + my $fh; + if (not open($fh, '<', $allowedKeyFile)) { + osh_exit 'ERR_INTERNAL', "Couldn't open authorized_keys file for read to remove the key"; + } + my @lines = <$fh>; + close($fh); + + # remove specified line + splice @lines, $lineNumberToDelete - 1, 1; + + if (not open($fh, '>', $allowedKeyFile)) { + osh_exit 'ERR_INTERNAL', "Couldn't open authorized_keys file for write to remove the key"; + } + + print $fh @lines; + close($fh); + + osh_ok R('OK', msg => "Key ID $lineNumberToDelete successfully deleted"); +} +else { + osh_exit 'ERR_MISSING_PARAMETER', "Line number to delete was not specified"; +} + +osh_exit 'ERR_INTERNAL'; diff --git a/bin/plugin/open/selfDelIngressKey.json b/bin/plugin/open/selfDelIngressKey.json new file mode 100644 index 0000000..2caabe2 --- /dev/null +++ b/bin/plugin/open/selfDelIngressKey.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "selfDelIngressKey" , {"ac" : ["--line-number-to-delete", "--fingerprint-to-delete", ""]}, + "selfDelIngressKey --line-number-to-delete" , {"pr" : [""]}, + "selfDelIngressKey --line-number-to-delete \\S+" , {"pr" : [""]}, + "selfDelIngressKey --fingerprint-to-delete" , {"pr" : [""]}, + "selfDelIngressKey --fingerprint-to-delete \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/open/selfForgetHostKey b/bin/plugin/open/selfForgetHostKey new file mode 100755 index 0000000..894b886 --- /dev/null +++ b/bin/plugin/open/selfForgetHostKey @@ -0,0 +1,94 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove a known hostkey", + options => {}, + helptext => <<'EOF', +Forget a known host key from your bastion account + +Usage: --osh SCRIPT_NAME [--host HOST] [--port PORT] + + --host HOST Host to remove from the known_hosts file + --port PORT Port to look for in the known_hosts file (default: 22) + +This command is useful to remove the man-in-the-middle warning when a key has changed, +however please verify that the host key change is legit before using this command. +The warning SSH gives is there for a reason. +EOF +); + +# +# code +# +my $fnret; + +if (not $host and not $ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing argument 'host'"; +} + +my @toCheck; +if ($port && $port ne '22') { + if ($host) { + push @toCheck, "[$host]:$port"; + } + if ($ip and $host ne $ip) { + push @toCheck, "[$ip]:$port"; + } +} +else { + if ($host) { + push @toCheck, $host; + } + if ($ip and $host ne $ip) { + push @toCheck, $ip; + } +} + +my $result_hash = {}; +foreach my $item (@toCheck) { + next unless $item; + osh_info "Looking for keys in your known_hosts for $item"; + + # if the user's known_hosts doesn't exist, it makes ssh-keygen -F very angry + if (!-e "$HOME/.ssh/known_hosts") { + osh_ok(R('OK_NO_CHANGE', msg => "Your account didn't have any known_hosts file, nothing to do")); + } + + my @command = qw{ ssh-keygen -F }; + push @command, $item; + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1, noisy_stdout => 1); + if ($fnret->err eq 'OK' || $fnret->value->{'sysret'} == 1) { + if (not @{$fnret->value->{'stdout'} || []}) { + $result_hash->{$item} = {action => 'OK_NO_MATCH'}; + osh_info "... none found (maybe you forgot to specify --port ?)"; + next; + } + osh_info "At least one key was found, deleting it..."; + @command = qw{ ssh-keygen -R }; + push @command, $item; + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1, noisy_stdout => 1); + if ($fnret->err eq 'OK') { + $result_hash->{$item} = {action => 'OK_DELETED'}; + osh_info "Key for '$item' deleted successfully"; + } + else { + $result_hash->{$item} = {action => 'ERR_DELETE_FAILED'}; + osh_crit "An error occurred while trying to delete the key (" . $fnret->msg . ")"; + } + } + else { + osh_exit 'ERR_SSH_KEYGEN_FIND', "An error occurred while trying to look for the key (" . $fnret->msg . ")"; + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/open/selfForgetHostKey.json b/bin/plugin/open/selfForgetHostKey.json new file mode 100644 index 0000000..fd01749 --- /dev/null +++ b/bin/plugin/open/selfForgetHostKey.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "selfForgetHostKey" , {"ac" : ["--host"]}, + "selfForgetHostKey --host" , {"pr" : [""]}, + "selfForgetHostKey --host \\S+" , {"ac" : ["", "--port"]}, + "selfForgetHostKey --host \\S+ --port" , {"pr" : [""]}, + "selfForgetHostKey --host \\S+ --port \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/selfGenerateEgressKey b/bin/plugin/open/selfGenerateEgressKey new file mode 100755 index 0000000..c0f3603 --- /dev/null +++ b/bin/plugin/open/selfGenerateEgressKey @@ -0,0 +1,137 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ReadKey; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT ); + +my ($algo, $size, $encrypted); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "generating a new key pair for your account", + options => { + "algo=s" => \$algo, + "size=i" => \$size, + "encrypted" => \$encrypted, + }, + help => \&help, +); + +sub help { + require Term::ANSIColor; + my $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'egress'); + my @algoList = @{$fnret->value}; + my $algos = Term::ANSIColor::colored(uc join(' ', @algoList), 'green'); + my $helpAlgoSize = '--algo rsa --size 4096'; + if (grep { $_ eq 'ecdsa' } @algoList) { + $helpAlgoSize = '--algo ecdsa --size 521'; + } + if (grep { $_ eq 'ed25519' } @algoList) { + $helpAlgoSize = '--algo ed25519'; + } + osh_info <<"EOF"; +Create a new egress public + private key pair. The private key will stay on your account on this bastion. + +Usage: --osh $scriptName $helpAlgoSize [--encrypted] + + --algo ALGO Specifies the algo of the key, either rsa, ecdsa or ed25519. + + --size SIZE Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (4096 is good). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + + --encrypted if specified, a passphrase will be prompted for the new key + +With the policy and SSH version on this bastion, +the following algorithms are supported: $algos + +algo size strength speed compatibility +------- ---- ---------- -------- ----------------------- +RSA 4096 good slow works everywhere +ECDSA 521 strong fast debian7+ (OpenSSH 5.7+) +ED25519 256 verystrong veryfast debian8+ (OpenSSH 6.5+) +EOF + return 0; +} + +# +# code +# +my $fnret; + +if (!$algo) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Parameter 'algo' is missing"; +} + +# check if algo is supported by system +$fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'egress'); +my @algoList = @{$fnret->value}; +my $ok = 0; +foreach (@algoList) { + $algo =~ /^\Q$_\E/ and $ok = 1; +} +if (not $ok) { + osh_debug($algo); + osh_debug(join(' ', @algoList)); + osh_exit 'ERR_INVALID_ALGORITHM', "Only the following list of algorithms is allowed: " . join(' ', @algoList); +} + +$size = 256 if (not $size and $algo eq 'ed25519'); +$fnret = OVH::Bastion::is_allowed_algo_and_size(algo => $algo, size => $size, way => 'egress'); +$fnret or osh_exit $fnret; + +$fnret = OVH::Bastion::config('bastionName'); +$fnret or osh_exit $fnret; +my $bastionName = $fnret->value; + +my $passphrase = ''; # empty by default +if ($encrypted) { + print "Please enter a passphrase for your new personal bastion key (not echoed): "; + ReadMode('noecho'); + chomp(my $pass1 = ); + if (length($pass1) < 5) { + + # ssh-keygen will refuse + print "\n"; + osh_exit 'ERR_PASSPHRASE_TOO_SHORT', "Passphrase needs to be at least 5 chars"; + } + print "\nPlease enter it again: "; + chomp(my $pass2 = ); + print "\n"; + ReadMode('restore'); + if ($pass1 ne $pass2) { + osh_exit 'ERR_PASSPHRASE_MISMATCH', "Passphrases don't match, please try again"; + } + $passphrase = $pass1; +} + +osh_info "Generating your key, this might take a while..."; +$fnret = OVH::Bastion::generate_ssh_key( + folder => OVH::Bastion::get_home_from_env()->value . '/.ssh', + prefix => 'private', + name => $self, + algo => $algo, + size => $size, + passphrase => $passphrase, +); +$fnret or osh_exit $fnret; + +osh_info "You new key pair has been generated:\n"; +$fnret = OVH::Bastion::get_ssh_pub_key_info(file => $fnret->value->{'file'} . ".pub", way => "egress"); +$fnret or osh_exit $fnret; +my $key = $fnret->value; + +$fnret = OVH::Bastion::get_bastion_ips(); +$fnret or osh_exit $fnret; + +$key->{'prefix'} = 'from="' . join(',', @{$fnret->value}) . '"'; + +OVH::Bastion::print_public_key(key => $key); + +osh_ok($key); diff --git a/bin/plugin/open/selfGenerateEgressKey.json b/bin/plugin/open/selfGenerateEgressKey.json new file mode 100644 index 0000000..33d1d8b --- /dev/null +++ b/bin/plugin/open/selfGenerateEgressKey.json @@ -0,0 +1,12 @@ +{ + "interactive": [ + "selfGenerateEgressKey" , {"ac" : ["--algo"]}, + "selfGenerateEgressKey --algo" , {"ac" : ["rsa", "ecdsa", "ed25519"]}, + "selfGenerateEgressKey --algo \\S+" , {"ac" : ["--size"]}, + "selfGenerateEgressKey --algo \\S+ --size" , {"pr" : [""]}, + "selfGenerateEgressKey --algo \\S+ --size \\d+" , {"ac" : ["", "--encrypted"]}, + "selfGenerateEgressKey --algo \\S+ --size \\d+ --encrypted" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "raw" +} diff --git a/bin/plugin/open/selfGeneratePassword b/bin/plugin/open/selfGeneratePassword new file mode 100755 index 0000000..e66c90c --- /dev/null +++ b/bin/plugin/open/selfGeneratePassword @@ -0,0 +1,70 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::generatePassword; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "generating a new egress password for your account", + options => { + "size=i" => \my $size, + "do-it" => \my $doIt, + }, + helptext => <<'EOF' +Generate a new egress password for your account + +Usage: --osh SCRIPT_NAME [--size SIZE] --do-it + + --size SIZE Specify the number of characters of the password to generate + --do-it Required for the password to actually be generated, BEWARE: please read the note below + +This plugin generates a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for your account, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. +EOF +); + +# code +my $fnret; + +$size = 16 if not defined $size; + +$fnret = OVH::Bastion::Plugin::generatePassword::preconditions(self => $self, context => 'account', account => $self, size => $size); +$fnret or osh_exit($fnret); + +# get returned untainted value +$self = $fnret->value->{'account'}; + +$fnret = OVH::Bastion::plugin_config(plugin => $scriptName, key => "minPasswordSize"); +if ($fnret && $fnret->value && $size < $fnret->value) { + osh_exit('ERR_INVALID_PARAMETER', "The minimum allowed password size defined by policy is " . $fnret->value . " characters, you asked only $size"); +} + +if (not $doIt) { + help(); + osh_exit('ERR_MISSING_PARAMETER', "Missing mandatory parameter: please read the BEWARE note above."); +} + +$fnret = OVH::Bastion::Plugin::generatePassword::act(self => $self, context => 'account', account => $self, size => $size); +$fnret or osh_exit($fnret); + +osh_info "Generated a new password of length $size for your account, $self, hashes follow:"; +osh_info "md5crypt: " . $fnret->value->{'hashes'}{'md5crypt'} . "\n"; +osh_info "sha256crypt: " . $fnret->value->{'hashes'}{'sha256crypt'} . "\n"; +osh_info "sha512crypt: " . $fnret->value->{'hashes'}{'sha512crypt'} . "\n"; +osh_info "This new password will now be used by default."; +osh_exit $fnret; diff --git a/bin/plugin/open/selfGeneratePassword.json b/bin/plugin/open/selfGeneratePassword.json new file mode 100644 index 0000000..0e00503 --- /dev/null +++ b/bin/plugin/open/selfGeneratePassword.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "selfGeneratePassword" , {"ac" : ["--size",""]}, + "selfGeneratePassword --size" , {"pr" : [""]}, + "selfGeneratePassword --size \\S+" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/open/selfGenerateProxyPassword b/bin/plugin/open/selfGenerateProxyPassword new file mode 100755 index 0000000..032a016 --- /dev/null +++ b/bin/plugin/open/selfGenerateProxyPassword @@ -0,0 +1,72 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "generating a new ingress https password for your account", + options => { + "do-it" => \my $doIt, + }, + helptext => <<'EOF' +Generate a new ingress password to use the bastion HTTPS proxy + +Usage: --osh SCRIPT_NAME [--size SIZE] --do-it + + --size SIZE Size of the password to generate + --do-it Required for the password to actually be generated, BEWARE: please read the note below + +This plugin generates a new ingress password to use the bastion HTTPS proxy. + +NOTE: this is only needed for devices that only support HTTPS API and not ssh, +in most cases you should ignore this command completely, unless you +know that devices you need to access are using an HTTPS API. + +BEWARE: once a new password is generated this way, it'll be set as the new +HTTPS proxy ingress password to use right away for your account. +EOF +); + +# code +my $fnret; + +if (not $doIt) { + help(); + osh_exit('ERR_MISSING_PARAMETER', "Missing mandatory parameter: please read the BEWARE note above."); +} + +# generate a password +my $pass = ''; +my $antiloop = 1000; +RETRY: while ($antiloop-- > 0) { + $pass .= chr(int(rand(ord('~') - ord('!')) + ord('!'))) for (1 .. 32); + if ($pass =~ m{["'\\`:@]}) { + $pass = ''; + next; + } + last; +} +if ($antiloop <= 0) { + osh_exit('ERR_CANNOT_GENERATE_PASSWORD', "Couldn't generate password, please try again"); +} + +# get the corresponding hashes +$fnret = OVH::Bastion::get_hashes_from_password(password => $self . ':' . $pass); +$fnret or osh_exit $fnret; + +my $hash = $fnret->value->{'sha512crypt'}; +osh_exit('ERR_NO_HASH', "Couldn't generate a valid hash") if !$hash; + +# push the sha512crypt hash in the proper file +$fnret = OVH::Bastion::account_config(account => $self, key => "proxyhttphash", value => $hash); +$fnret or osh_exit $fnret; + +osh_info "Generated a new ingress HTTPS proxy password for your account, $self, here it is:"; +osh_info $pass; +osh_ok({password => $pass}); diff --git a/bin/plugin/open/selfGenerateProxyPassword.json b/bin/plugin/open/selfGenerateProxyPassword.json new file mode 100644 index 0000000..801c263 --- /dev/null +++ b/bin/plugin/open/selfGenerateProxyPassword.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "selfGenerateProxyPassword" , {"ac" : ["--size",""]}, + "selfGenerateProxyPassword --size" , {"pr" : [""]}, + "selfGenerateProxyPassword --size \\S+" , {"ac" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/open/selfListAccesses b/bin/plugin/open/selfListAccesses new file mode 100755 index 0000000..ef31f43 --- /dev/null +++ b/bin/plugin/open/selfListAccesses @@ -0,0 +1,43 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($hideGroups, $reverse); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "your access list", + options => { + "hide-groups" => \$hideGroups, + "reverse-dns" => \$reverse, + }, + helptext => <<'EOF', +Show the list of servers you have access to + +Usage: --osh SCRIPT_NAME [--hide-groups] [--reverse-dns] + + --hide-groups Don't show the machines you have access to through group rights. In other words, + list only your private accesses. + + --reverse-dns Attempt to resolve the reverse hostnames (SLOW!) +EOF +); + +my $fnret; + +$fnret = OVH::Bastion::get_acls(account => $self); +$fnret or osh_exit $fnret; + +if (not @{$fnret->value}) { + osh_ok R('OK_EMPTY', msg => "Dear $self, you have no registered accesses to machines through this bastion yet"); +} + +osh_info "Dear $self, you have access to the following servers:\n"; + +OVH::Bastion::print_acls(acls => $fnret->value, reverse => $reverse, hideGroups => $hideGroups); +osh_ok($fnret); diff --git a/bin/plugin/open/selfListAccesses.json b/bin/plugin/open/selfListAccesses.json new file mode 100644 index 0000000..a01e09b --- /dev/null +++ b/bin/plugin/open/selfListAccesses.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "selfListAccesses" , {"ac" : ["", "--hide-groups", "--reverse-dns"]}, + "selfListAccesses --hide-groups" , {"ac" : ["", "--reverse-dns"]}, + "selfListAccesses --reverse-dns" , {"ac" : ["", "--hide-groups"]}, + "selfListAccesses --(reverse-dns|hide-groups) --(reverse-dns|hide-groups)" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/selfListEgressKeys b/bin/plugin/open/selfListEgressKeys new file mode 100755 index 0000000..b099caa --- /dev/null +++ b/bin/plugin/open/selfListEgressKeys @@ -0,0 +1,55 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor qw{ colored }; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "your account's public ingress keys", + options => {}, + helptext => <<'EOF', +List the public egress keys of your account + +Usage: --osh SCRIPT_NAME + +The keys listed are the public egress SSH keys tied to your account. +They can be used to gain access to another machine from this bastion, +by putting one of those keys in the remote machine's ``authorized_keys`` file, +and adding yourself access to this machine with ``selfAddPersonalAccess``. +EOF +); + +my $fnret; + +$fnret = OVH::Bastion::get_bastion_ips(); +$fnret or osh_exit $fnret; + +my $from = 'from="' . join(',', @{$fnret->value}) . '"'; + +$fnret = OVH::Bastion::get_personal_account_keys(account => $sysself); +$fnret or osh_exit $fnret; + +osh_info "You can copy one of those keys to a remote machine to get access to it through your account"; +osh_info "on this bastion, if it is listed in your private access list (check selfListAccesses)"; +osh_info " "; +osh_info "Always include the $from part when copying the key to a server!"; +osh_info " "; + +my $result_hash = {}; +foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + $key->{'prefix'} = $from; + undef $key->{'filename'}; + undef $key->{'fullpath'}; + OVH::Bastion::print_public_key(key => $key); + $result_hash->{$key->{'fingerprint'}} = $key; +} + +osh_ok $result_hash; diff --git a/bin/plugin/open/selfListIngressKeys b/bin/plugin/open/selfListIngressKeys new file mode 100755 index 0000000..88c9dfb --- /dev/null +++ b/bin/plugin/open/selfListIngressKeys @@ -0,0 +1,39 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "Here are the public keys that allow you to connect to the bastion", + helptext => <<'EOF', +List the public ingress keys of your account + +Usage: --osh SCRIPT_NAME + +The keys listed are the public ingress SSH keys tied to your account. +Their private counterpart should be detained only by you, and used +to authenticate yourself to this bastion. +EOF +); + +my $fnret; + +$fnret = OVH::Bastion::get_authorized_keys_from_file(file => "$HOME/.ssh/authorized_keys2", includeInvalid => 1); +$fnret or osh_exit $fnret; + +my @result; +foreach my $key (@{$fnret->value || []}) { + OVH::Bastion::print_public_key(key => $key, id => $key->{'index'}, err => $key->{'err'}); + $key->{'validity'} = delete $key->{'err'}; + $key->{'id'} = delete $key->{'index'}; + $key->{'from_list'} = delete $key->{'fromList'}; + push @result, $key; +} + +osh_ok({keys => \@result, account => $self}); diff --git a/bin/plugin/open/selfListIngressKeys.json b/bin/plugin/open/selfListIngressKeys.json new file mode 100644 index 0000000..304f977 --- /dev/null +++ b/bin/plugin/open/selfListIngressKeys.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "selfListIngressKeys" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/selfListPasswords b/bin/plugin/open/selfListPasswords new file mode 100755 index 0000000..b8d27a6 --- /dev/null +++ b/bin/plugin/open/selfListPasswords @@ -0,0 +1,41 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "list your egress passwords", + options => {}, + helptext => <<'EOF' +List the hashes and metadata of the egress passwords associated to your account + +Usage: --osh SCRIPT_NAME + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH +EOF +); + +# code +my $fnret; + +$fnret = OVH::Bastion::get_hashes_list(context => 'account', account => $self); +$fnret or osh_exit $fnret; + +foreach my $item (@{$fnret->value}) { + osh_info $item->{'description'}; + foreach my $hash (sort keys %{$item->{'hashes'}}) { + osh_info "... $hash: " . $item->{'hashes'}{$hash}; + } + osh_info "\n"; +} +if (not @{$fnret->value}) { + osh_info "You don't have any egress password configured"; +} + +osh_ok($fnret); diff --git a/bin/plugin/open/selfListPasswords.json b/bin/plugin/open/selfListPasswords.json new file mode 100644 index 0000000..bcdb26b --- /dev/null +++ b/bin/plugin/open/selfListPasswords.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "selfListPasswords" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/open/selfListSessions b/bin/plugin/open/selfListSessions new file mode 100755 index 0000000..6c334bc --- /dev/null +++ b/bin/plugin/open/selfListSessions @@ -0,0 +1,194 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; +use POSIX (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "your past sessions list", + options => { + "detailed" => \my $detailed, + "id=s" => \my $id, + "type=s" => \my $type, + "allowed" => \my $allowed, + "denied" => \my $denied, + "after=s" => \my $after, + "before=s" => \my $before, + "from=s" => \my $from, + "via=s" => \my $via, + "via-port=i" => \my $viaPort, + "to-port=i" => \my $toPort, + "limit=i" => \my $limit, + }, + helptext => <<'EOF', +List the few past sessions of your account + +Usage: --osh SCRIPT_NAME [OPTIONS] + + --detailed Display more informations about each session + --limit LIMIT Limit to LIMIT results + --id ID Only sessions having this ID + --type TYPE Only sessions of specified type (ssh, osh, ...) + --allowed Only sessions that have been allowed by the bastion + --denied Only sessions that have been denied by the bastion + --after WHEN Only sessions that started after WHEN, + WHEN can be a TIMESTAMP, or YYYY-MM-DD[@HH:MM:SS] + --before WHEN Only sessions that started before WHEN, + WHEN can be a TIMESTAMP, or YYYY-MM-DD[@HH:MM:SS] + --host HOST Only sessions connecting to remote HOST + --to-port PORT Only sessions connecting to remote PORT + --user USER Only sessions connecting using remote USER + --via HOST Only sessions that connected through bastion IP HOST + --via-port PORT Only sessions that connected through bastion PORT + +Note that only the sessions that happened on this precise bastion instance will be shown, +not the sessions from its possible cluster siblings. +EOF +); + +# +# code +# +my $fnret; + +sub makeTimestamp { + my ($data, $name) = @_; + + if ($data =~ m'^(\d{4})[/-](\d\d)[/-](\d\d)(@(\d\d):(\d\d):(\d\d))?$') { + return POSIX::mktime($7, $6, $5, $3, $2 - 1, $1 - 1900); + } + osh_exit R('ERR_INVALID_PARAMETER', msg => "--$name: expected a date of the format YYYY-MM-DD or YYY-MM-DD\@HH:MM:SS or UNIXTIMESTAMP"); +} + +my ($afterTimestamp, $beforeTimestamp); +$afterTimestamp = makeTimestamp($after, 'after') if $after; +$beforeTimestamp = makeTimestamp($before, 'before') if $before; + +$limit = 100 if not defined $limit; +if (defined $limit and $limit !~ /^\d+$/) { + osh_exit R('ERR_INVALID_PARAMETER', msg => "Expected a numeric limit"); +} + +my $allowedParam = undef; +$allowedParam = 1 if $allowed; +$allowedParam = 0 if $denied; + +$fnret = OVH::Bastion::log_access_get( + account => $self, + uniqid => $id, + cmdtype => $type, + after => $afterTimestamp, + before => $beforeTimestamp, + allowed => $allowedParam, + ipfrom => $from, + ipto => $host, + portto => $toPort, + bastionip => $via, + bastionport => $viaPort, + toPort => $toPort, + user => $user, + limit => $limit +); +$fnret or osh_exit $fnret; + +if (not %{$fnret->value}) { + osh_ok R('OK_EMPTY', msg => "No session found"); +} +else { + osh_info "The list of your" . ($limit ? " $limit" : "") . " past sessions follows:"; + osh_info ' '; + my $list = $fnret->value; + + my @result; + foreach my $id (sort { $list->{$b} <=> $list->{$a} } keys %{$list}) { + my $r = $list->{$id}; + my $diff = + ($r->{timestampend} + $r->{timestampendusec} / 1_000_000) - ($r->{timestamp} + $r->{timestampusec} / 1_000_000); + + my $delay = '-.-'; + if ($r->{timestampend}) { + my $d = int($delay / 86400); + $delay -= $d * 86400; + my $h = int($delay / 3600); + $delay -= $h * 3600; + my $m = int($delay / 60); + $delay -= $m * 60; + my $s = int($delay); + $delay -= $s; + my $ds = int($delay * 10); + + if ($d > 0) { + $delay = sprintf('%dd+%02d:%02d:%02d.%d', $d, $h, $m, $s, $ds); + } + elsif ($h > 0) { + $delay = sprintf('%02d:%02d:%02d.%d', $h, $m, $s, $ds); + } + elsif ($m > 0) { + $delay = sprintf('%02d:%02d.%d', $m, $s, $ds); + } + elsif ($s > 0) { + $delay = sprintf('%02d.%d', $s, $ds); + } + else { + $delay = sprintf('0.%d', $ds); + } + } + $delay = sprintf('%13s', $delay); + + my $to = + $r->{user} + || $r->{ipto} + || $r->{portto} + || $r->{hostto} ? sprintf(' to %s@%s:%s(%s)', $r->{'user'}, $r->{'ipto'}, $r->{'portto'}, $r->{'hostto'}) : ''; + $r->{params} = undef if ($r->{cmdtype} ne 'osh'); + $r->{returnvalue} = $r->{comment} if $r->{returnvalue} < 0; + + if ($detailed) { + printf "%s [%s - %s (%s)] type %s from %s:%s(%s) via %s@%s:%s%s returned %s%s\n", + $r->{uniqid}, POSIX::strftime("%Y/%m/%d@%H:%M:%S", localtime($r->{timestamp})), + $r->{timestampend} ? POSIX::strftime("%Y/%m/%d@%H:%M:%S", localtime($r->{timestampend})) : '????/??/??@??:??:??', + $delay, + $r->{'cmdtype'} . ($r->{'plugin'} ? '-' . $r->{'plugin'} : '') . ($r->{allowed} ? '' : '/DENIED'), + $r->{'ipfrom'}, $r->{'portfrom'}, $r->{'hostfrom'}, $r->{'account'}, $r->{'bastionip'}, $r->{'bastionport'}, + $to, defined $r->{returnvalue} ? $r->{returnvalue} : 'null', + $r->{params} ? " params $r->{params}" : ''; + } + else { + printf "%s [%s] %s%s%s\n", + $r->{uniqid}, POSIX::strftime("%Y/%m/%d@%H:%M:%S", localtime($r->{timestamp})), + $r->{'cmdtype'} . ($r->{'plugin'} ? '-' . $r->{'plugin'} : '') . ($r->{allowed} ? '' : '/DENIED'), $r->{params} ? ' ' . $r->{params} : '', + $to; + } + push @result, + { + id => $r->{uniqid}, + from => {ip => $r->{ipfrom}, host => $r->{hostfrom}, port => $r->{portfrom}}, + via => {ip => $r->{bastionip}, port => $r->{bastionport}, user => $r->{account}}, + to => {ip => $r->{ipto}, port => $r->{portto}, host => $r->{hostto}}, + timestamp_started => $r->{timestamp} + $r->{timestampusec} / 1_000_000, + timestamp_ended => $r->{timestampend} + $r->{timestampendusec} / 1_000_000, + type => $r->{cmdtype}, + plugin => $r->{plugin}, + allowed => $r->{allowed}, + returned => $r->{returnvalue}, + params => $r->{params}, + }; + } + + if (@result == $limit) { + osh_info "\nResults limited to $limit, but there might be more matching the given criteria,"; + osh_info "you might want to re-run with a higher limit, or with stricter criteria,"; + osh_info "check the --help of this command for more information."; + } + + osh_ok(\@result); +} + +osh_exit 'ERR_INTERNAL'; diff --git a/bin/plugin/open/selfMFAResetPassword b/bin/plugin/open/selfMFAResetPassword new file mode 100755 index 0000000..7d17ae9 --- /dev/null +++ b/bin/plugin/open/selfMFAResetPassword @@ -0,0 +1,29 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove the UNIX password of your account (MFA)", + options => {}, + helptext => <<'EOF' +Remove the UNIX password of your account + +Usage: --osh SCRIPT_NAME + +Note that if your password is set, you'll be prompted for it. +Also note that this doesn't remove your UNIX password requirement, if set (see ``accountModify`` for this). +EOF +); + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountMFAResetPassword'; +push @command, "--account", $self; + +osh_exit(OVH::Bastion::helper(cmd => \@command)); diff --git a/bin/plugin/open/selfMFAResetPassword.json b/bin/plugin/open/selfMFAResetPassword.json new file mode 100644 index 0000000..9398ff8 --- /dev/null +++ b/bin/plugin/open/selfMFAResetPassword.json @@ -0,0 +1,6 @@ +{ + "interactive": [ + "selfMFAResetPassword" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/open/selfMFAResetTOTP b/bin/plugin/open/selfMFAResetTOTP new file mode 100755 index 0000000..26bf343 --- /dev/null +++ b/bin/plugin/open/selfMFAResetTOTP @@ -0,0 +1,29 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove the TOTP configuration of your account (MFA)", + options => {}, + helptext => <<'EOF' +Remove the TOTP configuration of your account + +Usage: --osh SCRIPT_NAME + +Note that if your TOTP is set, you'll be prompted for it. +Also note that this doesn't remove your TOTP requirement, if set (see accountModify for this). +EOF +); + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountMFAResetTOTP'; +push @command, "--account", $self; + +osh_exit(OVH::Bastion::helper(cmd => \@command)); diff --git a/bin/plugin/open/selfMFAResetTOTP.json b/bin/plugin/open/selfMFAResetTOTP.json new file mode 100644 index 0000000..b1a3bdf --- /dev/null +++ b/bin/plugin/open/selfMFAResetTOTP.json @@ -0,0 +1,6 @@ +{ + "interactive": [ + "selfMFAResetTOTP" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/open/selfMFASetupPassword b/bin/plugin/open/selfMFASetupPassword new file mode 100755 index 0000000..32f8317 --- /dev/null +++ b/bin/plugin/open/selfMFASetupPassword @@ -0,0 +1,88 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "setup a UNIX password for your local account", + options => {'yes' => \my $yes}, + helptext => <<'EOF' +Setup an additional credential (UNIX password) to access your account + +Usage: --osh SCRIPT_NAME [--yes] + + --yes Don't ask for confirmation +EOF +); + +my $fnret; +my @command; + +if (OVH::Bastion::config('accountMFAPolicy')->value eq 'disabled') { + osh_exit('ERR_DISABLED_BY_POLICY', "Sorry, Multi-Factor Authentication has been disabled by policy on this bastion"); +} + +if ($ENV{'OSH_NO_INTERACTIVE'}) { + osh_exit('ERR_PRECONDITIONS_FAILED', + "For security reasons, this plugin can't be used in interactive mode.\nTo ensure you're the owner of the account, please call it the regular way (i.e. --osh $scriptName)"); +} + +# check if we have a valid password or an invalid/locked one +@command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-selfMFASetupPassword'; +push @command, '--account', $self, '--step', '0'; +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +if ($fnret->value->{'password'} ne 'set') { + + # ok, we need to set the password to a temporary valid one, but as people don't read, + # tell them what we'll do and expect them to say 'y' + if (!$yes) { + osh_info "As you currently don't have any password set, we'll setup a temporary one that you'll be asked to change right away."; + osh_info "Enter 'y' to proceed, anything else to abort."; + chomp(my $ans = ); + if ($ans ne 'y') { + osh_exit(R('OK_NO_CHANGE', msg => "Aborted per user request")); + } + } + + @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-selfMFASetupPassword'; + push @command, '--account', $self, '--step', '1'; + + $fnret = OVH::Bastion::helper(cmd => \@command); + $fnret or osh_exit $fnret; + + osh_info "We've set a temporary password so you can change it."; + osh_info "On the prompt for your current UNIX password, enter this: " . $fnret->value->{'password'}; +} + +while (1) { + + # BSD doesn't attach our caller tty correctly when using OVH::Bastion::execute, so using system() here + system('passwd'); + if ($? != 0) { + osh_warn("Error while changing your password! Try again:"); + sleep(1); + next; + } + + last; +} + +# apply password policy parameters (expiration, etc) +@command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-selfMFASetupPassword'; +push @command, '--account', $self, '--step', '2'; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +osh_ok; diff --git a/bin/plugin/open/selfMFASetupPassword.json b/bin/plugin/open/selfMFASetupPassword.json new file mode 100644 index 0000000..9646d13 --- /dev/null +++ b/bin/plugin/open/selfMFASetupPassword.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "selfMFASetupPassword" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/open/selfMFASetupTOTP b/bin/plugin/open/selfMFASetupTOTP new file mode 100755 index 0000000..8a9c600 --- /dev/null +++ b/bin/plugin/open/selfMFASetupTOTP @@ -0,0 +1,75 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "setup TOTP for your account", + options => { + 'no-confirm' => \my $noConfirm, + }, + helptext => <<'EOF' +Setup an additional credential (TOTP) to access your account + +Usage: --osh SCRIPT_NAME [--no-confirm] + + --no-confirm Bypass the confirmation step for TOTP enrollment phase +EOF +); + +my $fnret; +my @command; + +if (OVH::Bastion::config('accountMFAPolicy')->value eq 'disabled') { + osh_exit('ERR_DISABLED_BY_POLICY', "Sorry, Multi-Factor Authentication has been disabled by policy on this bastion"); +} + +if ($ENV{'OSH_NO_INTERACTIVE'}) { + osh_exit('ERR_PRECONDITIONS_FAILED', + "For security reasons, this plugin can't be used in interactive mode.\nTo ensure you're the owner of the account, please call it the regular way (i.e. --osh $scriptName)"); +} + +# do the TOTP enrollment + +# first, check if the google-authenticator we have supports --issuer, if not, just omit it, it's not a deal-breaker +$fnret = OVH::Bastion::execute(cmd => ['google-authenticator', '-h'], must_succeed => 1); +$fnret or HEXIT($fnret); +my @additional_params; +if (grep { /--issuer/ } @{$fnret->value->{'stdout'}}) { + push @additional_params, "--issuer=" . OVH::Bastion::config('bastionName')->value; +} +if ($noConfirm && grep { /--no-confirm/ } @{$fnret->value->{'stdout'}}) { + push @additional_params, "--no-confirm"; +} + +@command = ( + 'script', '-q', '-c', "google-authenticator -f -t -Q UTF8 -r 3 -R 15 -w 2 -D " . join(" ", @additional_params) . " -l $self -s $HOME/" . OVH::Bastion::TOTP_FILENAME, + '/dev/null' +); +{ + local $ENV{'SHELL'} = '/bin/sh'; + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1, noisy_stdout => 1, expects_stdin => 1, is_binary => 1, must_succeed => 1); +} +$fnret or osh_exit $fnret; + +if (!$fnret) { + osh_exit('ERR_TOTP_SETUP_FAILED', msg => "Couldn't setup TOTP for your account, try again!"); +} + +chmod 0400, $HOME . '/' . OVH::Bastion::TOTP_FILENAME; + +# it worked, add the account to the proper system group +@command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-selfMFASetupTOTP'; +push @command, '--account', $self; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +osh_exit $fnret; diff --git a/bin/plugin/open/selfMFASetupTOTP.json b/bin/plugin/open/selfMFASetupTOTP.json new file mode 100644 index 0000000..266c5ec --- /dev/null +++ b/bin/plugin/open/selfMFASetupTOTP.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "selfMFASetupTOTP" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/open/selfPlaySession b/bin/plugin/open/selfPlaySession new file mode 100755 index 0000000..c147774 --- /dev/null +++ b/bin/plugin/open/selfPlaySession @@ -0,0 +1,99 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "replay a past session", + options => { + "id=s" => \my $id, + }, + helptext => <<'EOF', +Replay the ttyrec of a past session + +Usage: --osh SCRIPT_NAME --id ID + + --id ID ID of the session to replay, use ``selfListSessions`` to find it. +EOF +); + +# +# code +# +my $fnret; + +if (not $id) { + help(); + osh_exit R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter ID"); +} + +$fnret = OVH::Bastion::log_access_get(account => $self, uniqid => $id); +$fnret or osh_exit $fnret; + +my $hashkey = (keys %{$fnret->value})[0]; +my $r = $fnret->value->{$hashkey}; +if (not defined $r) { + osh_exit R('ERR_NOT_FOUND', msg => "Found no session under this ID"); +} + +my $diff = ($r->{timestampend} + $r->{timestampendusec} / 1_000_000) - ($r->{timestamp} + $r->{timestampusec} / 1_000_000); +$diff = 0 if not $r->{timestampend}; +my $delay = $diff; +if (not $r->{timestampend}) { + $delay = 'n/a'; +} +else { + my $d = int($delay / 86400); + $delay -= $d * 86400; + my $h = int($delay / 3600); + $delay -= $h * 3600; + my $m = int($delay / 60); + $delay -= $m * 60; + my $s = int($delay); + $delay -= $s; + my $ds = int($delay * 1_000_000); + $delay = sprintf('%dd+%02d:%02d:%02d.%06d', $d, $h, $m, $s, $ds); +} +$r->{params} = undef if ($r->{cmdtype} ne 'osh'); +$r->{returnvalue} = $r->{comment} if $r->{returnvalue} < 0; + +osh_info sprintf "%8s: %s\n", "ID", $r->{uniqid}; +osh_info sprintf "%8s: %s\n", "Started", POSIX::strftime("%Y/%m/%d %H:%M:%S", localtime($r->{timestamp})); +osh_info sprintf "%8s: %s\n", "Ended", $r->{timestampend} ? POSIX::strftime("%Y/%m/%d %H:%M:%S", localtime($r->{timestampend})) : 'n/a'; +osh_info sprintf "%8s: %s\n", "Duration", $delay; +osh_info sprintf "%8s: %s\n", "Type", $r->{'cmdtype'} . ($r->{'plugin'} ? '-' . $r->{'plugin'} : '') . ($r->{allowed} ? '' : '/DENIED'); +osh_info sprintf "%8s: %s:%s (%s)\n", "From", $r->{'ipfrom'}, $r->{'portfrom'}, $r->{'hostfrom'}; +osh_info sprintf "%8s: %s@%s:%s\n", "Via", $r->{'account'}, $r->{'bastionip'}, $r->{'bastionport'}; +if ($r->{user} || $r->{ipto} || $r->{portto} || $r->{hostto}) { + osh_info sprintf "%8s: %s@%s:%s (%s)\n", "To", $r->{'user'}, $r->{'ipto'}, $r->{'portto'}, $r->{'hostto'}; +} +osh_info sprintf "%8s: %s%s\n", "RetCode", defined $r->{returnvalue} ? $r->{returnvalue} : 'n/a', $r->{params} ? " params $r->{params}" : ''; +osh_info "\n"; + +my $ttyrecfile = $r->{'ttyrecfile'}; +if (!$ttyrecfile) { + osh_info "There were no terminal recording for this session"; + osh_ok {}; +} + +# if we used on-the-fly compression, maybe the file ends in ".zst" +$ttyrecfile .= ".zst" if !-r $ttyrecfile; + +if (!-r $ttyrecfile) { + osh_exit R('ERR_NOT_FOUND', msg => "Recorded session for ID $id couldn't be found, it might have been archived"); +} + +osh_warn "Press '+' to play faster"; +osh_warn "Press '-' to play slower"; +osh_warn "Press '1' to restore normal playing speed"; +osh_warn "\nWhen you're ready to replay session $id, press ENTER."; +osh_warn "Starting from the next line, the Total Recall begins. Press CTRL+C to jolt awake."; +; +my $sysret = system('ttyplay', $ttyrecfile); +osh_ok {}; diff --git a/bin/plugin/open/selfPlaySession.json b/bin/plugin/open/selfPlaySession.json new file mode 100644 index 0000000..57791cf --- /dev/null +++ b/bin/plugin/open/selfPlaySession.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "selfPlaySession" , {"ac" : ["--id"]}, + "selfPlaySession --id" , {"pr" : [""]}, + "selfPlaySession --id \\S+" , {"pr" : [""]} + ], + "terminal_mode": "cbreak", + "execution_mode": "binary" +} diff --git a/bin/plugin/open/unlock b/bin/plugin/open/unlock new file mode 100755 index 0000000..3b31b15 --- /dev/null +++ b/bin/plugin/open/unlock @@ -0,0 +1,47 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "unlock all your current sessions", + options => { + 'report' => \my $report, + }, + helptext => <<'EOF', +Unlock all your current sessions + +Usage: --osh SCRIPT_NAME + +This command will unlock all your current sessions on this bastion instance, +that were either locked for inactivity timeout or manually locked by you with ``lock``. +Note that this only applies to the bastion instance you're launching this +command on, not on the whole bastion cluster (if you happen to have one). +EOF +); + +# code +my $fnret; +my @command; + +@command = qw{ pkill -c --uid }; +push @command, $self; +push @command, qw{ -USR2 ttyrec }; + +osh_info "Unlocking all your sessions..."; + +$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 0, noisy_stderr => 1); +$fnret or osh_exit $fnret; + +if ($fnret->value && $fnret->value->{'stdout'}) { + my $nb = $fnret->value->{'stdout'}->[0] / 2; + osh_info "Sent unlock signal to $nb session" . ($nb == 1 ? '' : 's'); +} + +osh_ok {}; diff --git a/bin/plugin/open/unlock.json b/bin/plugin/open/unlock.json new file mode 100644 index 0000000..470df87 --- /dev/null +++ b/bin/plugin/open/unlock.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "unlock" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountAddPersonalAccess b/bin/plugin/restricted/accountAddPersonalAccess new file mode 100755 index 0000000..1e7c758 --- /dev/null +++ b/bin/plugin/restricted/accountAddPersonalAccess @@ -0,0 +1,124 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "adding personal access to a server on an account", + options => { + "account=s" => \my $account, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + "force-key=s" => \my $forceKey, + "ttl=s" => \my $ttl, + "comment=s" => \my $comment, + }, + helptext => <<'EOF', +Add a personal server access to an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT --host HOST [OPTIONS] + + --account Bastion account to add the access to + --host IP|HOST|IP/MASK Server to add access to + --user USER Remote login to use, if you want to allow any login, use --user-any + --user-any Allow access with any remote login + --port PORT Remote SSH port to use, if you want to allow any port, use --port-any + --port-any Allow access to all remote ports + --scpup Allow SCP upload, you--bastion-->server (omit --user in this case) + --scpdown Allow SCP download, you<--bastion--server (omit --user in this case) + --force-key FINGERPRINT Only use the key with the specified fingerprint to connect to the server (cf selfListEgressKeys) + --ttl SECONDS|DURATION Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + --comment "'ANY TEXT'" Add a comment alongside this server. Quote it twice as shown if you're under a shell. + +The access will work only if one of the account's personal egress public key has been copied to the remote server. +To get the list of an account's personal egress public keys, see ``accountListEgressKeyss`` and ``selfListEgressKeys``. +EOF +); + +my $fnret; + +if (!$ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter 'host' or didn't resolve correctly"; +} + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --user and --user-any, please check what you're doing"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + osh_warn "You didn't specify --user or --user-any, defaulting to --user-any, this will no longer be implicit in future versions"; + $userAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to allow any remote user, use --user-any"; +} + +if ($port and $portAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --port and --port-any, please check what you're doing"; +} +if (not $port and not $portAny) { + osh_warn "You didn't specify --port or --port-any, defaulting to --port-any, this will no longer be implicit in future versions"; + $portAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to allow any remote port, use --port-any"; +} + +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +if ($forceKey) { + $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $forceKey); + $fnret or osh_exit $fnret; + $forceKey = $fnret->value->{'fingerprint'}; +} + +osh_info "Can't verify if $account\'s personal key have been installed to the remote server, as you don't have access to his private keys, adding the access blindly"; + +my @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess'; +push @command, '--target', 'any'; +push @command, '--action', 'add'; +push @command, '--account', $account; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; +push @command, '--force-key', $forceKey if $forceKey; +push @command, '--ttl', $ttl if $ttl; +push @command, '--comment', $comment if $comment; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountAddPersonalAccess.json b/bin/plugin/restricted/accountAddPersonalAccess.json new file mode 100644 index 0000000..76a58a9 --- /dev/null +++ b/bin/plugin/restricted/accountAddPersonalAccess.json @@ -0,0 +1,22 @@ +{ + "interactive": [ + "accountAddPersonalAccess" , {"ac" : ["--account"]}, + "accountAddPersonalAccess --account" , {"ac" : [""]}, + "accountAddPersonalAccess --account \\S+" , {"ac" : ["--host"]}, + "accountAddPersonalAccess --account \\S+ --host" , {"pr" : ["", "", ""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ .*--user" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ .*--port" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"ac" : ["--force-key","--ttl",""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl \\S+" , {"ac" : ["--force-key",""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl \\S+ --force-key" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+" , {"ac" : ["--ttl",""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+ --ttl" , {"pr" : [""]}, + "accountAddPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+ --ttl \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountCreate b/bin/plugin/restricted/accountCreate new file mode 100755 index 0000000..f6a5132 --- /dev/null +++ b/bin/plugin/restricted/accountCreate @@ -0,0 +1,131 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "create a new bastion account", + options => { + 'uid=i' => \my $uid, + 'account=s' => \my $account, + 'always-active' => \my $alwaysActive, + 'pubKey=s' => \my $pubKey, # deprecated, keep it not to break scripts or people + 'public-key=s' => \my $pubKey, + 'comment=s' => \my $comment, + 'uid-auto' => \my $uidAuto, + 'osh-only' => \my $oshOnly, + 'immutable-key' => \my $immutableKey, + 'no-key' => \my $noKey, + 'ttl=s' => \my $ttl, + }, + helptext => <<'EOF', +Create a new bastion account + +Usage: --osh SCRIPT_NAME --account ACCOUNT [OPTIONS] + + --account NAME Account name to create, NAME must contain only valid UNIX account name characters + --uid UID Account system UID, also see --uid-auto + --uid-auto Auto-select an UID from the allowed range (the upper available one will be used) + --always-active This account's activation won't be challenged on connection, even if the bastion is globally + configured to check for account activation + --osh-only This account will only be able to use OSH commands, and not connecting to machines (ssh or telnet) + --immutable-key Deny any subsequent modification of the account key (selfAddKey and selfDelKey are denied) + --comment '"STRING"' An optional comment when creating the account. Quote it twice as shown if you're under a shell. + --public-key '"KEY"' Account public SSH key to deposit on the bastion, if not present, + you'll be prompted interactively for it. Quote it twice as shown if your're under a shell. + --no-key Don't prompt for an SSH key, no ingress public key will be installed + --ttl SECONDS|DURATION Time after which the account will be deactivated (amount of seconds, or duration string such as "4d12h15m") +EOF +); + +# ugly hack for space-enabled parameter +# XXX should be removed, double quoting fixes the problem, but keep it for compatibility +if (ref $remainingOptions eq 'ARRAY' and @$remainingOptions) { + $pubKey .= " " . join(" ", @$remainingOptions); +} + +# +# code +# +my $fnret; + +# +# params check +# + +if ((not $account) or (not defined $uid and not $uidAuto)) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account' or ('uid' or 'uid-auto')"; +} + +# quicky ensure these params are not pure bullshit (real check is done by helper script) +if ($account !~ /^[a-z0-9._-]+$/i) { + osh_exit 'ERR_INVALID_PARAMETER', "Parameter 'account' seems invalid"; +} + +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +if (defined $uid && $uid == 0) { + osh_exit 'ERR_IN_YOUR_DREAMS', "Tu l'as vu ?"; +} + +if (defined $uid && $uidAuto) { + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "Can't specify UID and ask auto UID assignment at the same time"; +} + +if (defined $pubKey && $noKey) { + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "Can't use pubKey as you also specified noKey"; +} + +if (!$pubKey && !$noKey) { + $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'ingress'); + $fnret or osh_exit $fnret; + my @algoList = @{$fnret->value}; + my $algos = join(' ', @algoList); + osh_info "Please paste the SSH key you want to add. This bastion supports the following algorithms:\n"; + if (grep { 'ed25519' eq $_ } @algoList) { + osh_info "ED25519: strongness[#####] speed[#####], use `ssh-keygen -t ed25519' to generate one"; + } + if (grep { 'ecdsa' eq $_ } @algoList) { + osh_info "ECDSA : strongness[####.] speed[#####], use `ssh-keygen -t ecdsa -b 521' to generate one"; + } + if (grep { 'rsa' eq $_ } @algoList) { + osh_info "RSA : strongness[###..] speed[#....], use `ssh-keygen -t rsa -b 4096' to generate one"; + } + osh_info "\nIn any case, don't save it without a passphrase (your paste won't be echoed)."; + $pubKey = ; + ## use critic +} + +if (!$noKey) { + $fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress'); + $fnret or osh_exit $fnret; +} + +# +# Now create it +# +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountCreate'; +push @command, "--type", "normal"; +push @command, "--account", $account; +push @command, "--pubKey", $pubKey if !$noKey; +push @command, "--always-active" if $alwaysActive; +push @command, "--comment", $comment if $comment; +push @command, "--uid", $uid if defined $uid; +push @command, "--osh-only", $oshOnly if $oshOnly; +push @command, "--uid-auto" if $uidAuto; +push @command, "--immutable-key" if $immutableKey; +push @command, '--ttl', $ttl if $ttl; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountCreate.json b/bin/plugin/restricted/accountCreate.json new file mode 100644 index 0000000..7e6d69c --- /dev/null +++ b/bin/plugin/restricted/accountCreate.json @@ -0,0 +1,14 @@ +{ + "interactive": [ + "accountCreate" , {"ac" : ["--account"]}, + "accountCreate --account" , {"pr" : [""]}, + "accountCreate --account \\S+" , {"ac" : ["--uid"]}, + "accountCreate --account \\S+ --uid" , {"pr" : [""]}, + "accountCreate --account \\S+ --uid \\S+" , {"ac" : ["", "--always-active", "--comment"]}, + "accountCreate --account \\S+ --uid \\S+ --always-active" , {"ac" : ["", "--comment"]}, + "accountCreate --account \\S+ --uid \\S+ .*--comment" , {"pr" : [""]}, + "accountCreate --account \\S+ --uid \\S+ (--always-active --comment \\S+|--comment \\S+ --always-active)" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "raw" +} diff --git a/bin/plugin/restricted/accountDelPersonalAccess b/bin/plugin/restricted/accountDelPersonalAccess new file mode 100755 index 0000000..44cf7f4 --- /dev/null +++ b/bin/plugin/restricted/accountDelPersonalAccess @@ -0,0 +1,98 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "removing personal access to a server from an account", + options => { + "account=s" => \my $account, + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + }, + helptext => <<'EOF', +Remove a personal server access from an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT --host HOST [OPTIONS] + + --account Bastion account to remove access from + --host IP|HOST|IP/MASK Server to remove access from + --user USER Remote user that was allowed, if any user was allowed, use --user-any + --user-any Use if any remote login was allowed + --port PORT Remote SSH port that was allowed, if any port was allowed, use --port-any + --port-any Use if any remote port was allowed + --scpup Remove SCP upload right, you--bastion-->server (omit --user in this case) + --scpdown Remove SCP download right, you<--bastion--server (omit --user in this case) +EOF +); + +my $fnret; + +if (!$ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter 'host' or didn't resolve correctly"; +} + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --user and --user-any, please check what you're doing"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + osh_warn "You didn't specify --user or --user-any, defaulting to --user-any, this will no longer be implicit in future versions"; + $userAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to allow any remote user, use --user-any"; +} + +if ($port and $portAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --port and --port-any, please check what you're doing"; +} +if (not $port and not $portAny) { + osh_warn "You didn't specify --port or --port-any, defaulting to --port-any, this will no longer be implicit in future versions"; + $portAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to allow any remote port, use --port-any"; +} + +my @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess'; +push @command, '--target', 'any'; +push @command, '--action', 'del'; +push @command, '--account', $account; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountDelPersonalAccess.json b/bin/plugin/restricted/accountDelPersonalAccess.json new file mode 100644 index 0000000..11c48ed --- /dev/null +++ b/bin/plugin/restricted/accountDelPersonalAccess.json @@ -0,0 +1,15 @@ +{ + "interactive": [ + "accountDelPersonalAccess" , {"ac" : ["--account"]}, + "accountDelPersonalAccess --account" , {"ac" : [""]}, + "accountDelPersonalAccess --account \\S+" , {"ac" : ["--host"]}, + "accountDelPersonalAccess --account \\S+ --host" , {"pr" : ["", "", ""]}, + "accountDelPersonalAccess --account \\S+ --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "accountDelPersonalAccess --account \\S+ --host \\S+ .*--user" , {"pr" : [""]}, + "accountDelPersonalAccess --account \\S+ --host \\S+ .*--port" , {"pr" : [""]}, + "accountDelPersonalAccess --account \\S+ --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "accountDelPersonalAccess --account \\S+ --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "accountDelPersonalAccess --account \\S+ --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountDelete b/bin/plugin/restricted/accountDelete new file mode 100755 index 0000000..41934d9 --- /dev/null +++ b/bin/plugin/restricted/accountDelete @@ -0,0 +1,99 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; +use POSIX (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "delete an existing bastion account", + options => { + 'account=s' => \my $account, + 'i-am-a-robot-and-i-dont-know-how-to-answer-your-question|no-confirm' => \my $noConfirm, + }, + helptext => <<'EOF', +Delete an account from the bastion + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Account name to delete + --no-confirm Don't ask for confirmation, and blame yourself if you deleted the wrong account +EOF +); + +# +# code +# +my $fnret; + +# +# params check +# +if (!$account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'account' parameter"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; # untaint + +if (!$noConfirm) { + osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; + osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; + osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; + osh_info " "; +} + +$fnret = OVH::Bastion::is_account_active(account => $account); +if ($fnret) { + osh_warn "Hint: account $account is currently ACTIVE (i.e. not disabled), think twice before removing it!"; +} +elsif ($fnret->is_err) { + osh_warn "Hint: couldn't verify $account current validity, in any case, think twice before removing it!"; +} +else { + osh_info("Hint: account $account is currently " . colored("inactive", "green") . ", so what you're doing is probably fine."); +} + +osh_info " "; +if (!$noConfirm) { + osh_info "You are about to DELETE a bastion account, to be sure you're not drunk, type the following sentence:"; + osh_info " "; + osh_info ' "Yes, do as I say and delete , kthxbye" '; + osh_info " "; + my $sentence = ; + chomp $sentence; + + if ($sentence ne "Yes, do as I say and delete $account, kthxbye") { + osh_exit 'ERR_OPERATOR_IS_DRUNK', "You're drunk, apparently, aborted."; + } + osh_info "OK, proceeding..."; +} + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountDelete'; +push @command, "--type", "normal", "--account", $account; + +# as the helper can take a long time to complete (because of tar), and caller in front +# of his ssh console might close it's end without waiting, tar would get a SIGHUP and +# stop. we don't want this: use setsid to be in our own session. we fork() first because +# if we are a process group leader, setsid() would fail +my $child = fork(); +if (!defined $child) { + osh_warn "Couldn't fork(), proceeding without forking..."; +} +exit if $child; # parent + +# here, I'm the child: call setsid() +if (POSIX::setsid() == -1) { + osh_warn "Couldn't call setsid(), proceeding anyway..."; +} + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountDelete.json b/bin/plugin/restricted/accountDelete.json new file mode 100644 index 0000000..2c0b794 --- /dev/null +++ b/bin/plugin/restricted/accountDelete.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "accountDelete" , {"ac" : ["--account"]}, + "accountDelete --account" , {"ac" : [""]}, + "accountDelete --account \\S+" , {"ac" : ["", "--comment"]}, + "accountDelete --account \\S+ --comment" , {"pr" : [""]}, + "accountDelete --account \\S+ --comment \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountGeneratePassword b/bin/plugin/restricted/accountGeneratePassword new file mode 100755 index 0000000..a27012e --- /dev/null +++ b/bin/plugin/restricted/accountGeneratePassword @@ -0,0 +1,83 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::generatePassword; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "generating a new egress password for the account", + options => { + "size=i" => \my $size, + "account=s" => \my $account, + "do-it" => \my $doIt, + }, + helptext => <<'EOF' +Generate a new egress password for an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT [--size SIZE] --do-it + + --account ACCOUNT Specify which account you want to generate a password for + --size SIZE Specify the number of characters of the password to generate + --do-it Required for the password to actually be generated, BEWARE: please read the note below + +This plugin generates a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for the account, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. +EOF +); + +# code +my $fnret; + +$size = 16 if not defined $size; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected an --account argument"; +} + +$fnret = OVH::Bastion::Plugin::generatePassword::preconditions(self => $self, context => 'account', account => $account, size => $size); +$fnret or osh_exit($fnret); + +# get returned untainted value +$account = $fnret->value->{'account'}; + +$fnret = OVH::Bastion::plugin_config(plugin => $scriptName, key => "minPasswordSize"); +if ($fnret && $fnret->value && $size < $fnret->value) { + osh_exit('ERR_INVALID_PARAMETER', "The minimum allowed password size defined by policy is " . $fnret->value . " characters, you asked only $size"); +} + +if (not $doIt) { + help(); + osh_exit('ERR_MISSING_PARAMETER', "Missing mandatory parameter: please read the BEWARE note above."); +} + +my @command = qw{ sudo -n -u }; +push @command, $account; +push @command, qw{ -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGeneratePassword'; +push @command, "--account", $account, "--size", $size; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit($fnret); + +osh_info "Generated a new password of length $size for account $account, hashes follow:"; +osh_info "md5crypt: " . $fnret->value->{'hashes'}{'md5crypt'} . "\n"; +osh_info "sha256crypt: " . $fnret->value->{'hashes'}{'sha256crypt'} . "\n"; +osh_info "sha512crypt: " . $fnret->value->{'hashes'}{'sha512crypt'} . "\n"; +osh_info "This new password will now be used by default."; +osh_exit $fnret; diff --git a/bin/plugin/restricted/accountGeneratePassword.json b/bin/plugin/restricted/accountGeneratePassword.json new file mode 100644 index 0000000..a5a00a5 --- /dev/null +++ b/bin/plugin/restricted/accountGeneratePassword.json @@ -0,0 +1,11 @@ +{ + "interactive": [ + "accountGeneratePassword" , {"ac" : ["--account"]}, + "accountGeneratePassword --account" , {"ac" : [""]}, + "accountGeneratePassword --account \\S+" , {"ac" : ["--size",""]}, + "accountGeneratePassword --account \\S+ --size" , {"pr" : [""]}, + "accountGeneratePassword --account \\S+ --size \\S+" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "noecho" +} diff --git a/bin/plugin/restricted/accountGrantCommand b/bin/plugin/restricted/accountGrantCommand new file mode 100755 index 0000000..93fb304 --- /dev/null +++ b/bin/plugin/restricted/accountGrantCommand @@ -0,0 +1,63 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "granting access to a restricted osh command to an account", + options => { + "account=s" => \my $account, + "command=s" => \my $command, + }, + helptext => <<'EOF', +Grant access to a restricted command + +Usage: --osh SCRIPT_NAME --account ACCOUNT --command COMMAND + + --account ACCOUNT Bastion account to work on + --command COMMAND The name of the OSH plugin to grant (omit to get the list) + +Note that SCRIPT_NAME being a restricted command as any other, you can grant it to somebody else, +but then they'll be able to grant themselves or anybody else to this or any other restricted command. + +A specific command that can be granted is ``auditor``, it is not an osh plugin per-se, but activates +more verbose output for several other commands, suitable to audit rights or grants without needing +to be granted (e.g. to groups). +EOF +); + +my $fnret; + +if (!$command) { + $fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); + help(); + if ($fnret) { + my @plugins = keys %{$fnret->value}; + push @plugins, 'auditor'; + osh_info "\nList of possible commands to grant: " . join(" ", sort @plugins); + } + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'command'"; +} + +if (!$account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyCommand'; +push @command, '--action', 'grant'; +push @command, '--command', $command; +push @command, '--account', $account; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountGrantCommand.json b/bin/plugin/restricted/accountGrantCommand.json new file mode 100644 index 0000000..fd8b290 --- /dev/null +++ b/bin/plugin/restricted/accountGrantCommand.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "accountGrantCommand" , {"ac" : ["--account"]}, + "accountGrantCommand --account" , {"ac" : [""]}, + "accountGrantCommand --account \\S+" , {"ac" : ["--command"]}, + "accountGrantCommand --account \\S+ --command" , {"ac" : [""]}, + "accountGrantCommand --account \\S+ --command \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountInfo b/bin/plugin/restricted/accountInfo new file mode 100755 index 0000000..60c6c58 --- /dev/null +++ b/bin/plugin/restricted/accountInfo @@ -0,0 +1,254 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Sys::Hostname (); +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "account information", + options => {'account=s' => \my $account}, + helptext => <<'EOF', +Display some information about an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT The account name to work on +EOF +); + +my $fnret; + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; + +$fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); +$fnret or osh_exit $fnret; + +my %ret; +if (OVH::Bastion::is_admin(account => $account)) { + osh_info "$account is a bastion " . colored('admin', 'green'); + $ret{'is_admin'} = 1; +} +if (OVH::Bastion::is_super_owner(account => $account)) { + osh_info "$account is a bastion " . colored('superowner', 'green'); + $ret{'is_superowner'} = 1; +} +if (OVH::Bastion::is_auditor(account => $account)) { + osh_info "$account is a bastion " . colored('auditor', 'green'); + $ret{'is_auditor'} = 1; +} + +osh_info "$account has access to the following restricted commands:"; +my @granted; +foreach my $plugin (sort keys %{$fnret->value}) { + $fnret = OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin"); + if ($fnret) { + push @granted, $plugin; + osh_info "- $plugin"; + } +} +if (!@granted) { + osh_info "(none)"; +} +$ret{'allowed_commands'} = \@granted; + +$fnret = OVH::Bastion::get_group_list(groupType => "key"); +$fnret or osh_exit $fnret; + +osh_info "\nThis account is part of the following groups:"; + +my $result_hash = {}; +foreach my $name (sort keys %{$fnret->value}) { + my @flags; + push @flags, 'owner' if OVH::Bastion::is_group_owner(group => $name, account => $account); + push @flags, 'gatekeeper' if OVH::Bastion::is_group_gatekeeper(group => $name, account => $account); + push @flags, 'aclkeeper' if OVH::Bastion::is_group_aclkeeper(group => $name, account => $account); + push @flags, 'member' if OVH::Bastion::is_group_member(group => $name, account => $account); + push @flags, 'guest' if OVH::Bastion::is_group_guest(group => $name, account => $account); + if (@flags) { + my $line = sprintf "%18s", $name; + $line .= sprintf " %14s", colored(grep({ $_ eq 'owner' } @flags) ? 'Owner' : '-', 'red'); + $line .= sprintf " %19s", colored(grep({ $_ eq 'gatekeeper' } @flags) ? 'GateKeeper' : '-', 'yellow'); + $line .= sprintf " %18s", colored(grep({ $_ eq 'aclkeeper' } @flags) ? 'ACLKeeper' : '-', 'magenta'); + $line .= sprintf " %15s", colored(grep({ $_ eq 'member' } @flags) ? 'Member' : '-', 'green'); + $line .= sprintf " %14s", colored(grep({ $_ eq 'guest' } @flags) ? 'Guest' : '-', 'cyan'); + osh_info $line; + $result_hash->{$name} = {flags => \@flags, name => $name}; + } +} +if (not keys %$result_hash) { + osh_info "(none)"; +} + +$ret{'groups'} = $result_hash; + +my $canConnect = 1; +$ret{'always_active'} = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, public => 1) ? 1 : 0; +if ($ret{'always_active'}) { + $ret{'is_active'} = 1; + osh_info "This account is " . colored('always', 'green') . " active"; +} +else { + $fnret = OVH::Bastion::is_account_active(account => $account); + if ($fnret->is_ok) { + osh_info "\nThis account is " . colored('active', 'green'); + $ret{'is_active'} = 1; + } + elsif ($fnret->is_ko) { + osh_info "\nThis account is " . colored('INACTIVE', 'red'); + $canConnect = 0; + $ret{'is_active'} = 0; + } +} + +if (OVH::Bastion::is_auditor(account => $self)) { + $fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $account); + if ($fnret->is_ok) { + osh_info "This account is " . colored('not expired', 'green'); + $ret{'is_expired'} = 0; + } + elsif ($fnret->is_ko) { + osh_info "This account is " . colored('EXPIRED', 'red'); + $canConnect = 0; + $ret{'is_expired'} = 1; + } + else { + osh_warn "Error getting account expiration info (" . $fnret->msg . ")"; + } + + if (!$fnret->is_err) { + osh_info "As a consequence, this account " . ($canConnect ? colored("can", 'green') : colored("CANNOT", 'red')) . " connect to this bastion\n\n"; + $ret{'can_connect'} = $canConnect; + + my $seenBefore = 1; + if ($fnret->value->{'already_seen_before'}) { + osh_info "This account has already been used " . colored('at least once', 'green'); + $ret{'already_seen_before'} = 1; + } + else { + osh_info "This account has " . colored('NEVER', 'red') . " been used (yet)"; + $seenBefore = 0; + $ret{'already_seen_before'} = 0; + } + + if (defined $fnret->value->{'seconds'}) { + $fnret = OVH::Bastion::duration2human(seconds => $fnret->value->{'seconds'}, tense => "past"); + if ($fnret) { + my $seenBeforeStr = sprintf("%s on %s (%s ago)", $seenBefore ? "Last seen" : "Created", colored($fnret->value->{'date'}, 'magenta'), $fnret->value->{'duration'}); + osh_info $seenBeforeStr; + $ret{'last_activity_date'} = $seenBeforeStr; + } + } + } + + osh_info "\nAccount egress SSH config:"; + $fnret = OVH::Bastion::account_ssh_config_get(account => $account); + if ($fnret->err eq 'OK_EMPTY') { + osh_info "- (default)"; + $ret{'account_egress_ssh_config'}{'type'} = 'default'; + } + elsif ($fnret->err eq 'ERR_FILE_LOCALLY_MODIFIED') { + osh_info "- (locally modified!)"; + $ret{'account_egress_ssh_config'}{'type'} = 'locally_modified'; + } + elsif ($fnret) { + $ret{'account_egress_ssh_config'}{'type'} = 'custom'; + foreach my $key (sort keys %{$fnret->value}) { + osh_info "- $key " . $fnret->value->{$key}; + $ret{'account_egress_ssh_config'}{'items'}{$key} = $fnret->value->{$key}; + } + } + else { + $ret{'account_egress_ssh_config'}{'type'} = 'unknown'; + osh_info "- (unknown: " . $fnret . ")"; + } + + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY); + $ret{'ingress_piv_enforced'} = ($fnret && $fnret->value eq 'yes') ? 1 : 0; + osh_info "\nPIV-enforced policy for ingress keys on this account is " . ($ret{'ingress_piv_enforced'} ? colored('enabled', 'green') : colored('disabled', 'blue')); + if ($ret{'ingress_piv_enforced'}) { + + # if it's enabled, we might have a grace period for this account + $fnret = OVH::Bastion::account_config(account => $account, public => 1, key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE); + if ($fnret && $fnret->value > time()) { + my $expiry = $fnret->value - time(); + my $human = OVH::Bastion::duration2human(seconds => $expiry)->value; + osh_info "PIV grace period for this account is " . colored('set', 'green') . " and expires in " . $human->{'human'}; + $ret{'ingress_piv_grace'} = { + enabled => 1, + expiration_timestamp => $fnret->value, + seconds_remaining => $expiry, + expiration_date => $human->{'date'}, + time_remaining => $human->{'duration'}, + }; + } + } + + osh_info "\nAccount Multi-Factor Authentication status:"; + $ret{'mfa_password_required'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) ? 1 : 0; + $ret{'mfa_password_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) ? 1 : 0; + $ret{'mfa_password_configured'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) ? 1 : 0; + osh_info "- Additional password authentication is " . ($ret{'mfa_password_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; + osh_info "- Additional password authentication bypass is " . ($ret{'mfa_password_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')) . " for this account"; + osh_info "- Additional password authentication is " . ($ret{'mfa_password_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); + + $ret{'mfa_totp_required'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) ? 1 : 0; + $ret{'mfa_totp_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) ? 1 : 0; + $ret{'mfa_totp_configured'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) ? 1 : 0; + osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; + osh_info "- Additional TOTP authentication bypass is " . ($ret{'mfa_totp_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')) . " for this account"; + osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); + + $ret{'pam_auth_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP) ? 1 : 0; + osh_info "- PAM authentication bypass is " . ($ret{'pam_auth_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')); + + $ret{'personal_egress_mfa_required'} = OVH::Bastion::account_config(account => $account, key => "personal_egress_mfa_required")->value; + $ret{'personal_egress_mfa_required'} ||= 'none'; # no config means no mfa + osh_info "- MFA policy on personal accesses (using personal keys) on egress side is: " . $ret{'personal_egress_mfa_required'}; + + $ret{'idle_ignore'} = OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1) ? 1 : 0; + osh_info "\n- Account is immune to idle counter-measures: " . ($ret{'idle_ignore'} ? colored('yes', 'green') : colored('no', 'blue')); + + my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGetPasswordInfo'; + push @command, '--account', $account; + $fnret = OVH::Bastion::helper(cmd => \@command); + if ($fnret) { + $ret{'password'}{$_} = $fnret->value->{$_} for (keys %{$fnret->value}); + osh_info "Account PAM UNIX password information (used for password MFA):"; + if ($ret{'password'}{'password'} eq 'locked') { + osh_info "- No valid password is set"; + } + else { + osh_info "- Password is " . $ret{'password'}{'password'}; + } + osh_info "- Password was last changed on " . $ret{'password'}{'date_changed'}; + if ($ret{'password'}{'max_days'} == -1) { + osh_info "- Password will never expire"; + } + else { + osh_info "- Password must be changed every " . $ret{'password'}{'max_days'} . " days at least"; + osh_info "- A warning is displayed " . $ret{'password'}{'warn_days'} . " days before expiration"; + } + if ($ret{'password'}{'min_days'} != 0) { + osh_info "- The minimum time between two password changes is " . $ret{'password'}{'min_days'} . " days"; + } + if ($ret{'password'}{'max_days'} != -1) { + if ($ret{'password'}{'inactive_days'} != -1) { + osh_info "- Account will be disabled " . $ret{'password'}{'inactive_days'} . " days after password expiration"; + } + else { + osh_info "- Account will not be disabled after password expiration"; + } + } + } +} + +osh_ok(\%ret); diff --git a/bin/plugin/restricted/accountInfo.json b/bin/plugin/restricted/accountInfo.json new file mode 100644 index 0000000..7b0cc42 --- /dev/null +++ b/bin/plugin/restricted/accountInfo.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "accountInfo" , {"ac" : ["--account"]}, + "accountInfo --account" , {"ac" : [""]}, + "accountInfo --account \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountList b/bin/plugin/restricted/accountList new file mode 100755 index 0000000..a97b622 --- /dev/null +++ b/bin/plugin/restricted/accountList @@ -0,0 +1,169 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "list bastion accounts", + options => { + "inactive-only" => \my $inactiveOnly, + "realm-only" => \my $realmOnly, + "account=s" => \my $account, + "audit" => \my $audit, + }, + helptext => <<'EOF', +List the bastion accounts + +Usage: --osh SCRIPT_NAME [--account ACCOUNT] [--inactive-only] [--audit] + + --account ACCOUNT Only list the specified account. This is an easy way to check whether the account exists + --inactive-only Only list inactive accounts + --audit Show more verbose information (SLOW!), you need to be a bastion auditor +EOF +); + +sub tristate2str { + my $v = shift; + my $r = shift; + return (defined $v ? ($v ? colored('yes', $r ? 'red' : 'green') : colored('no', $r ? 'green' : 'red')) : colored('-', 'blue')); +} + +if ($realmOnly) { + osh_exit(R('ERR_INVALID_PARAMETER'), "Option --realm-only is no longer supported, use realmList instead"); +} + +my $fnret; +if ($account) { + $fnret = OVH::Bastion::get_account_list(accounts => [$account]); +} +else { + $fnret = OVH::Bastion::get_account_list(); +} + +$fnret or osh_exit $fnret; +my $accounts = $fnret->value; + +if ($audit && !OVH::Bastion::is_auditor(account => $self)) { + osh_exit(R('ERR_PERMISSION_DENIED', msg => "You need to be a bastion auditor to use --audit")); +} + +my $fnretPassword; +if ($audit) { + + # get UNIX password info for all accounts + my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGetPasswordInfo', '--all'; + $fnretPassword = OVH::Bastion::helper(cmd => \@command); +} + +my $result_hash = {}; +foreach my $account (sort keys %$accounts) { + my %states; + + $states{'is_active'} = undef; + $fnret = OVH::Bastion::is_account_active(account => $account); + if ($fnret->is_ok) { + next if $inactiveOnly; + $states{'is_active'} = 1; + } + elsif ($fnret->is_ko) { + $states{'is_active'} = 0; + } + + if ($audit) { + $fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $account); + $states{'is_expired'} = undef; + if ($fnret->is_ok) { + $states{'is_expired'} = 0; + } + elsif ($fnret->is_ko) { + $states{'is_expired'} = 1; + $states{'expired_days'} = $fnret->value->{'days'}; + } + + $states{'already_seen_before'} = undef; + if ($fnret->value && defined $fnret->value->{'already_seen_before'}) { + $states{'already_seen_before'} = $fnret->value->{'already_seen_before'} ? 1 : 0; + } + + $states{'last_activity'} = undef; + $states{'last_activity_timestamp'} = undef; + my $seconds = $fnret->value->{'seconds'}; + if (defined $seconds) { + $fnret = OVH::Bastion::duration2human(seconds => $seconds, tense => "past"); + if ($fnret) { + $states{'last_activity_timestamp'} = time() - $seconds; + $states{'last_activity'} = sprintf( + "%s on %s (%s ago)", + (defined $states{'already_seen_before'} ? ($states{'already_seen_before'} ? "Last seen" : "Created") : "Last activity"), + $fnret->value->{'date'}, + $fnret->value->{'duration'} + ); + } + } + + $states{'can_connect'} = ($states{'is_active'} && !$states{'is_expired'}) ? 1 : 0; + + $states{'mfa_password_required'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) ? 1 : 0; + $states{'mfa_password_configured'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) ? 1 : 0; + $states{'mfa_password_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) ? 1 : 0; + $states{'mfa_totp_required'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) ? 1 : 0; + $states{'mfa_totp_configured'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) ? 1 : 0; + $states{'mfa_totp_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) ? 1 : 0; + $states{'pam_auth_bypass'} = OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP) ? 1 : 0; + + if ($fnretPassword) { + $states{"password_$_"} = $fnretPassword->value->{$account}{$_} for (keys %{$fnretPassword->value->{$account}}); + } + } + + $result_hash->{$account} = \%states; + $result_hash->{$account}{'name'} = $account; + $result_hash->{$account}{'uid'} = $accounts->{$account}{'uid'}; + + if ($audit) { + my @mfaPassword; + push @mfaPassword, 'required' if $states{'mfa_password_required'}; + push @mfaPassword, 'enabled' if $states{'mfa_password_configured'}; + push @mfaPassword, 'bypass' if $states{'mfa_password_bypass'}; + my @mfaTOTP; + push @mfaTOTP, 'required' if $states{'mfa_totp_required'}; + push @mfaTOTP, 'enabled' if $states{'mfa_totp_configured'}; + push @mfaTOTP, 'bypass' if $states{'mfa_totp_bypass'}; + + osh_info sprintf( +"%-18s %6d active:%-12s expired:%-12s can_connect:%-12s already_seen:%-12s mfa_password:%-25s mfa_totp:%-25s pam_bypass:%-12s pass_status:%-15s pass_changed:%-10s pass_min_days:%-3d pass_max_days:%-3d pass_warn_days:%-3d %s\n", + $account, + $accounts->{$account}{'uid'}, + tristate2str($states{'is_active'}), + tristate2str($states{'is_expired'}, 1), + tristate2str($states{'can_connect'}), + tristate2str($states{'already_seen_before'}), + @mfaPassword ? colored(join(',', @mfaPassword), 'green') : colored('-', 'blue'), + @mfaTOTP ? colored(join(',', @mfaTOTP), 'green') : colored('-', 'blue'), + tristate2str($states{'pam_auth_bypass'}, 1), + ( + $states{'password_password'} eq 'locked' + ? colored('locked', 'blue') + : ($states{'password_password'} eq 'set' ? colored('set', 'green') : colored($states{'password_password'}, 'red')) + ), + $states{'password_date_changed'}, + $states{'password_min_days'}, + $states{'password_max_days'}, + $states{'password_warn_days'}, + $states{'last_activity'}, + ); + } + else { + osh_info sprintf("%-18s %6d\n", $account, $accounts->{$account}{'uid'}); + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/restricted/accountList.json b/bin/plugin/restricted/accountList.json new file mode 100644 index 0000000..046c194 --- /dev/null +++ b/bin/plugin/restricted/accountList.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "accountList" , {"ac" : ["--inactive-only","--realm-only","--account"]}, + "accountList --account" , {"ac" : [""]}, + "accountList --account \\S+" , {"pr" : [""]}, + "accountList --inactive-only" , {"pr" : [""]}, + "accountList --realm-only" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountListAccesses b/bin/plugin/restricted/accountListAccesses new file mode 100755 index 0000000..b71c9a2 --- /dev/null +++ b/bin/plugin/restricted/accountListAccesses @@ -0,0 +1,64 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($hideGroups, $reverse, $account); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "access list of a bastion account", + options => { + "hide-groups" => \$hideGroups, + "reverse-dns" => \$reverse, + "account=s" => \$account, + }, + helptext => <<'EOF', +View the expanded access list of a given bastion account + +Usage: --osh SCRIPT_NAME --account ACCOUNT [--hide-groups] [--reverse-dns] + + --account ACCOUNT The account to work on + + --hide-groups Don't show the machines the accouns has access to through group rights. + In other words, list only the account's private accesses. + + --reverse-dns Attempt to resolve the reverse hostnames (SLOW!) +EOF +); + +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "missing mandatory parameter account"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +# FIXME won't be able to see group accesses caller is not a member of +my $accessListResult = OVH::Bastion::get_acls(account => $account); +$accessListResult or osh_exit $accessListResult; + +if (not @{$accessListResult->value}) { + osh_ok R('OK_EMPTY', msg => "This account has no registered accesses to machines through this bastion yet"); +} +else { + osh_info "NOTE: KNOWN LIMITATION in this version: you won't be able to"; + osh_info "see access lists of group you're not yourself a member of."; + osh_info "For such cases, an error 'unable to access' will be printed."; + osh_info ' '; + osh_info "This account has access to the following servers:"; + osh_info ' '; + + OVH::Bastion::print_acls(acls => $accessListResult->value, reverse => $reverse, hideGroups => $hideGroups); + osh_ok($accessListResult); +} + +osh_exit 'ERR_INTERNAL'; diff --git a/bin/plugin/restricted/accountListAccesses.json b/bin/plugin/restricted/accountListAccesses.json new file mode 100644 index 0000000..84a2d48 --- /dev/null +++ b/bin/plugin/restricted/accountListAccesses.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "accountListAccesses" , {"ac" : ["--account"]}, + "accountListAccesses --account" , {"ac" : [""]}, + "accountListAccesses --account \\S+" , {"ac" : ["", "--hide-groups", "--reverse-dns"]}, + "accountListAccesses --account \\S+ --hide-groups" , {"ac" : ["", "--reverse-dns"]}, + "accountListAccesses --account \\S+ --reverse-dns" , {"ac" : ["", "--hide-groups"]}, + "accountListAccesses --account \\S+ --(reverse-dns|hide-groups) --(reverse-dns|hide-groups)" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountListEgressKeys b/bin/plugin/restricted/accountListEgressKeys new file mode 100755 index 0000000..e38f899 --- /dev/null +++ b/bin/plugin/restricted/accountListEgressKeys @@ -0,0 +1,66 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor qw{ colored }; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "public bastion key of an account", + options => { + "account=s" => \my $account, + }, + helptext => <<'EOF', +List the public egress keys of an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Account to display the public egress keys of + +The keys listed are the public egress SSH keys tied to this account. +They can be used to gain access to another machine from this bastion, +by putting one of those keys in the remote machine's ``authorized_keys`` file, +and adding this account access to this machine with ``accountAddPersonalAccess``. +EOF +); + +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'account' parameter"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +$fnret = OVH::Bastion::get_bastion_ips(); +$fnret or osh_exit $fnret; + +my $from = 'from="' . join(',', @{$fnret->value}) . '"'; + +my @command = qw{ sudo -n -u keyreader -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountListEgressKeys'; +push @command, ('--account', $account); + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +my $result_hash = {}; +foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + $key->{'prefix'} = $from; + undef $key->{'filename'}; + undef $key->{'fullpath'}; + OVH::Bastion::print_public_key(key => $key); + $result_hash->{$key->{'fingerprint'}} = $key; +} + +osh_ok $result_hash; diff --git a/bin/plugin/restricted/accountListEgressKeys.json b/bin/plugin/restricted/accountListEgressKeys.json new file mode 100644 index 0000000..29bbaa8 --- /dev/null +++ b/bin/plugin/restricted/accountListEgressKeys.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "accountListEgressKeys" , {"ac" : ["--account"]}, + "accountListEgressKeys --account" , {"ac" : [""]}, + "accountListEgressKeys --account \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountListIngressKeys b/bin/plugin/restricted/accountListIngressKeys new file mode 100755 index 0000000..cb0093d --- /dev/null +++ b/bin/plugin/restricted/accountListIngressKeys @@ -0,0 +1,59 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor qw{ colored }; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "public bastion key of an account", + options => { + "account=s" => \my $account, + }, + helptext => <<'EOF', +List the public ingress keys of an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Account to list the keys of + +The keys listed are the public ingress SSH keys tied to this account. +Their private counterpart should be detained only by this account's user, +so that they can to authenticate themselves to this bastion. +EOF +); + +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'account' parameter"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u keyreader -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountListIngressKeys'; +push @command, ('--account', $account); + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +my @result; +foreach my $key (@{$fnret->value || []}) { + OVH::Bastion::print_public_key(key => $key, id => $key->{'index'}, err => $key->{'err'}); + $key->{'validity'} = delete $key->{'err'}; + $key->{'id'} = delete $key->{'index'}; + $key->{'from_list'} = delete $key->{'fromList'}; + push @result, $key; +} + +osh_ok({keys => \@result, account => $account}); diff --git a/bin/plugin/restricted/accountListIngressKeys.json b/bin/plugin/restricted/accountListIngressKeys.json new file mode 100644 index 0000000..719e874 --- /dev/null +++ b/bin/plugin/restricted/accountListIngressKeys.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "accountListIngressKeys" , {"ac" : ["--account"]}, + "accountListIngressKeys --account" , {"ac" : [""]}, + "accountListIngressKeys --account \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountListPasswords b/bin/plugin/restricted/accountListPasswords new file mode 100755 index 0000000..bdfacc2 --- /dev/null +++ b/bin/plugin/restricted/accountListPasswords @@ -0,0 +1,54 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "account egress passwords", + options => {"account=s" => \my $account}, + helptext => <<'EOF' +List the hashes and metadata of the egress passwords associated to an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT The account name to work on + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH +EOF +); + +# code +my $fnret; + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; # untainted +my $sysaccount = $fnret->value->{'sysaccount'}; + +my @command = qw{ sudo -n -u }; +push @command, $sysaccount; +push @command, qw{ -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountListPasswords'; +push @command, '--account', $account; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +foreach my $item (@{$fnret->value}) { + osh_info $item->{'description'}; + foreach my $hash (sort keys %{$item->{'hashes'}}) { + osh_info "... $hash: " . $item->{'hashes'}{$hash}; + } + osh_info "\n"; +} +if (not @{$fnret->value}) { + osh_info "This account doesn't have any egress password configured"; +} + +osh_ok($fnret); diff --git a/bin/plugin/restricted/accountListPasswords.json b/bin/plugin/restricted/accountListPasswords.json new file mode 100644 index 0000000..884ef1b --- /dev/null +++ b/bin/plugin/restricted/accountListPasswords.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "accountListPasswords" , {"ac" : ["--account"]}, + "accountListPasswords --account" , {"ac" : [""]}, + "accountListPasswords --account \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountMFAResetPassword b/bin/plugin/restricted/accountMFAResetPassword new file mode 100755 index 0000000..b9ea3bd --- /dev/null +++ b/bin/plugin/restricted/accountMFAResetPassword @@ -0,0 +1,43 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove the UNIX password of an account (MFA)", + options => { + "account=s" => \my $account, + }, + helptext => <<'EOF' +Remove the UNIX password of an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Specify which account you want to remove the UNIX password of + +Note that if doesn't remove the account UNIX password requirement, if set (see ``accountModify`` for this) +EOF +); + +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected an --account argument"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountMFAResetPassword'; +push @command, "--account", $account; + +osh_exit(OVH::Bastion::helper(cmd => \@command)); diff --git a/bin/plugin/restricted/accountMFAResetPassword.json b/bin/plugin/restricted/accountMFAResetPassword.json new file mode 100644 index 0000000..2987459 --- /dev/null +++ b/bin/plugin/restricted/accountMFAResetPassword.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "accountMFAResetPassword" , {"ac" : ["--account"]}, + "accountMFAResetPassword --account" , {"ac" : [""]}, + "accountMFAResetPassword --account \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountMFAResetTOTP b/bin/plugin/restricted/accountMFAResetTOTP new file mode 100755 index 0000000..2971a5b --- /dev/null +++ b/bin/plugin/restricted/accountMFAResetTOTP @@ -0,0 +1,43 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "remove the TOTP configuration of an account (MFA)", + options => { + "account=s" => \my $account, + }, + helptext => <<'EOF' +Remove the TOTP configuration of an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Specify which account you want to remove the TOTP configuration of + +Note that if doesn't remove the TOTP requirement, if set (see ``accountModify`` for this). +EOF +); + +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Expected an --account argument"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountMFAResetTOTP'; +push @command, "--account", $account; + +osh_exit(OVH::Bastion::helper(cmd => \@command)); diff --git a/bin/plugin/restricted/accountMFAResetTOTP.json b/bin/plugin/restricted/accountMFAResetTOTP.json new file mode 100644 index 0000000..6cd574b --- /dev/null +++ b/bin/plugin/restricted/accountMFAResetTOTP.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "accountMFAResetTOTP" , {"ac" : ["--account"]}, + "accountMFAResetTOTP --account" , {"ac" : [""]}, + "accountMFAResetTOTP --account \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/accountModify b/bin/plugin/restricted/accountModify new file mode 100755 index 0000000..db6a1db --- /dev/null +++ b/bin/plugin/restricted/accountModify @@ -0,0 +1,95 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my %modify; +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "modify the configuration of an account", + options => { + "account=s" => \my $account, + "mfa-password-required=s" => \$modify{'mfa-password-required'}, + "mfa-totp-required=s" => \$modify{'mfa-totp-required'}, + "pam-auth-bypass=s" => \$modify{'pam-auth-bypass'}, + "always-active=s" => \$modify{'always-active'}, + "egress-strict-host-key-checking=s" => \$modify{'egress-strict-host-key-checking'}, + "personal-egress-mfa-required=s" => \$modify{'personal-egress-mfa-required'}, + "idle-ignore=s" => \$modify{'idle-ignore'}, + }, + helptext => <<'EOF', +Modify an account configuration + +Usage: --osh SCRIPT_NAME --account ACCOUNT [--option value [--option value [...]]] + + --account ACCOUNT Bastion account to work on + --pam-auth-bypass yes|no Enable or disable PAM auth bypass for this account in addition to pubkey auth (default is 'no'), + in that case sshd will not rely at all on PAM auth and /etc/pam.d/sshd configuration. This + does not change the behaviour of the code, just the PAM auth handled by SSH itself + --mfa-password-required yes|no|bypass Enable or disable UNIX password requirement for this account in addition to pubkey auth (default is 'no'), + this overrides the global bastion configuration 'accountMFAPolicy'. If 'bypass' is specified, + no password will ever be asked, even for groups or plugins explicitly requiring it + --mfa-totp-required yes|no|bypass Enable or disable TOTP requirement for this account in addition to pubkey auth (default is 'no'), + this overrides the global bastion configuration 'accountMFAPolicy'. If 'bypass' is specified, + no OTP will ever be asked, even for groups or plugins explicitly requiring it + --egress-strict-host-key-checking POLICY Modify the egress SSH behavior of this account regarding StrictHostKeyChecking (see man ssh_config), + POLICY can be 'yes', 'no', 'ask', 'default' or 'bypass' + --personal-egress-mfa-required POLICY Enforce UNIX password requirement, or TOTP requirement, or any MFA requirement, when connecting to a server + using the personal keys of the account, POLICY can be 'password', 'totp', 'any' or 'none' + --always-active yes|no Set or unset the account as always active (i.e. disable the check of the 'active' status on this account) + --idle-ignore yes|no If enabled, this account is immune to the idleLockTimeout and idleKillTimeout bastion-wide policy +EOF +); + +my $fnret; + +if (!$account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +if ((grep { defined } values %modify) == 0) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter to modify account"; +} + +foreach my $key (qw{ mfa-password-required mfa-totp-required }) { + next unless $modify{$key}; + if (not grep { $modify{$key} eq $_ } qw{ yes no bypass }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected '--$key yes' or '--$key no' or '--$key bypass' instead of '--$key $modify{$key}'"; + } +} +foreach my $key (qw{ always-active pam-auth-bypass idle-ignore }) { + next unless $modify{$key}; + if (not grep { $modify{$key} eq $_ } qw{ yes no }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected '--$key yes' or '--$key no' instead of '--$key $modify{$key}'"; + } +} +if ($modify{'egress-strict-host-key-checking'} && !grep { $modify{'egress-strict-host-key-checking'} eq $_ } qw{ yes no ask default bypass }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected option 'yes', 'no', 'ask', 'default' or 'bypass' to --egress-strict-host-key-checking"; +} +if ($modify{'personal-egress-mfa-required'} && !grep { $modify{'personal-egress-mfa-required'} eq $_ } qw{ password totp any none }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected option 'password', 'totp', 'any', 'none' to --personal-egress-mfa-required"; +} + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModify'; +push @command, '--account', $account; +foreach my $key (keys %modify) { + push @command, '--modify', $key . '=' . $modify{$key} if $modify{$key}; +} + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountModify.json b/bin/plugin/restricted/accountModify.json new file mode 100644 index 0000000..fa55842 --- /dev/null +++ b/bin/plugin/restricted/accountModify.json @@ -0,0 +1,12 @@ +{ + "interactive": [ + "accountModify" , {"ac": ["--account"]}, + "accountModify --account" , {"ac": [""]}, + "accountModify --account \\S+" , {"ac": ["--mfa-password-required","--mfa-totp-required","--pam-auth-bypass","--always-active","--egress-strict-host-key-checking","--personal-egress-mfa-required","--idle-ignore"]}, + "accountModify --account \\S+ .*(--mfa-password-required|--mfa-totp-required)" , {"ac": ["yes","no","bypass"]}, + "accountModify --account \\S+ .*(--pam-auth-bypass|--mfa-auth-bypass|--always-active|idle-ignore)", {"ac": ["yes","no"]}, + "accountModify --account \\S+ .*(--egress-strict-host-key-checking)" , {"ac": ["yes","no","ask","default","bypass"]}, + "accountModify --account \\S+ .*(--personal-egress-mfa-required)" , {"ac": ["password","totp","any","none"]}, + "accountModify --account \\S+ .*(yes|no|bypass|ask|default|totp|password|none)" , {"ac": ["--mfa-password-required","--mfa-totp-required","--pam-auth-bypass","--always-active","--egress-strict-host-key-checking","--personal-egress-mfa-required","--idle-ignore",""]} + ] +} diff --git a/bin/plugin/restricted/accountPIV b/bin/plugin/restricted/accountPIV new file mode 100755 index 0000000..67223a8 --- /dev/null +++ b/bin/plugin/restricted/accountPIV @@ -0,0 +1,91 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "modify the PIV policy of an account", + options => { + "account=s" => \my $account, + "policy=s" => \my $policy, + "ttl=s" => \my $ttl, + }, + helptext => <<'EOF', +Modify the PIV policy for the ingress keys of an account + +Usage: --osh SCRIPT_NAME --account ACCOUNT --policy + +Options: + --account ACCOUNT Bastion account to work on + --policy none|enforce|grace Changes the PIV policy of account. 'none' disables the PIV enforcement, any SSH key can be used + as long as it respects the bastion policy. 'enforce' enables the PIV enforcement, only PIV keys + can be added as ingress SSH keys. 'grace' enables temporary deactivation of PIV enforcement on + an account, only meaningful when policy is already set to 'enforce' for this account, 'grace' + requires the use of the --ttl option to specify how much time the policy will be relaxed for this + account before going back to 'enforce' automatically. + --ttl SECONDS|DURATION For the 'grace' policy, amount of time after which the account will automatically go back to 'enforce' + policy (amount of seconds, or duration string such as "4d12h15m") +EOF +); + +my $fnret; + +if (!$account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +if (not grep { $policy eq $_ } qw{ none enforce grace }) { + help(); + osh_exit 'ERR_INVALID_PARAMETER', "Expected either 'none,' enforce' of 'grace' as a parameter to --policy"; +} + +if ($policy eq 'grace' && !defined $ttl) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "The use of 'grace' requires to specify the --ttl parameter as well"; +} + +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +my @command; + +osh_info "Changing account configuration..."; + +@command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountPIV'; +push @command, '--step', '1'; +push @command, '--account', $account; +push @command, '--policy', $policy; +push @command, '--ttl', $ttl if defined $ttl; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; +osh_info $fnret->msg; + +osh_info "Applying change to keys..."; + +@command = qw{ sudo -n -u }; +push @command, $account; +push @command, qw{ -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountPIV'; +push @command, '--step', '2'; +push @command, '--account', $account; +push @command, '--policy', $policy; +push @command, '--ttl', $ttl if defined $ttl; + +$fnret = OVH::Bastion::helper(cmd => \@command); +osh_exit $fnret; diff --git a/bin/plugin/restricted/accountPIV.json b/bin/plugin/restricted/accountPIV.json new file mode 100644 index 0000000..7a6fd2a --- /dev/null +++ b/bin/plugin/restricted/accountPIV.json @@ -0,0 +1,12 @@ +{ + "interactive": [ + "accountPIV" , {"ac": ["--account"]}, + "accountPIV --account" , {"ac": [""]}, + "accountPIV --account \\S+" , {"ac": ["--policy"]}, + "accountPIV --account \\S+ --policy" , {"ac": ["none","enforce","grace"]}, + "accountPIV --account \\S+ --policy (none|enforce)" , {"pr": [""]}, + "accountPIV --account \\S+ --policy grace" , {"ac": ["--ttl"]}, + "accountPIV --account \\S+ --policy grace --ttl" , {"pr": [""]}, + "accountPIV --account \\S+ --policy grace --ttl (.*)", {"pr": [""]} + ] +} diff --git a/bin/plugin/restricted/accountRevokeCommand b/bin/plugin/restricted/accountRevokeCommand new file mode 100755 index 0000000..dda95d7 --- /dev/null +++ b/bin/plugin/restricted/accountRevokeCommand @@ -0,0 +1,56 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "revoking access to a restricted osh command from an account", + options => { + "account=s" => \my $account, + "command=s" => \my $command, + }, + helptext => <<'EOF', +Revoke access to a restricted command + +Usage: --osh SCRIPT_NAME --account ACCOUNT --command COMMAND + + --account ACCOUNT Bastion account to work on + --command COMMAND The name of the OSH plugin to revoke access to (omit to get the list) +EOF +); + +my $fnret; + +if (!$command) { + $fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); + help(); + if ($fnret) { + my @plugins = keys %{$fnret->value}; + push @plugins, 'auditor'; + osh_info "\nList of possible commands to revoke: " . join(" ", sort @plugins); + } + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'command'"; +} + +if (!$account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'account'"; +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyCommand'; +push @command, '--action', 'revoke'; +push @command, '--command', $command; +push @command, '--account', $account; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/accountRevokeCommand.json b/bin/plugin/restricted/accountRevokeCommand.json new file mode 100644 index 0000000..7e21a37 --- /dev/null +++ b/bin/plugin/restricted/accountRevokeCommand.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "accountRevokeCommand" , {"ac" : ["--account"]}, + "accountRevokeCommand --account" , {"ac" : [""]}, + "accountRevokeCommand --account \\S+" , {"ac" : ["--command"]}, + "accountRevokeCommand --account \\S+ --command" , {"ac" : [""]}, + "accountRevokeCommand --account \\S+ --command \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/accountUnexpire b/bin/plugin/restricted/accountUnexpire new file mode 100755 index 0000000..e0757c6 --- /dev/null +++ b/bin/plugin/restricted/accountUnexpire @@ -0,0 +1,66 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor qw{ colored }; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my ($account); +OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "unexpire an inactivity-expired account", + options => {"account=s" => \$account}, + helptext => <<'EOF', +Unexpire an inactivity-expired account + +Usage: --osh SCRIPT_NAME --account ACCOUNT + + --account ACCOUNT Account to work on + +When the bastion is configued to expire accounts that haven't been seen in a while, +this command can be used to activate them back. +EOF +); + +# +# code +# +my $fnret; + +if (not $account) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'account' parameter"; +} + +# Here we parse account name +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); +$fnret or osh_exit $fnret; +$account = $fnret->value->{'account'}; + +my @command = qw{ sudo -n -u }; +push @command, ($account, '--'); +push @command, qw{ /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountUnexpire'; +push @command, ('--account', $account); + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +if ($fnret->err eq 'OK') { + my $days = $fnret->value->{'days'}; + osh_ok R( + 'OK', + value => {account => $account, days => $days}, + msg => "Account $account was expired ($days days without connection), it is now active again." + ); +} +elsif ($fnret->is_ok) { + osh_ok R('OK_NO_CHANGE', msg => "Account $account wasn't expiring, no change was needed or made."); +} + +osh_exit $fnret; diff --git a/bin/plugin/restricted/accountUnexpire.json b/bin/plugin/restricted/accountUnexpire.json new file mode 100644 index 0000000..43d8ef2 --- /dev/null +++ b/bin/plugin/restricted/accountUnexpire.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "accountUnexpire" , {"ac" : ["--account"]}, + "accountUnexpire --account" , {"ac" : [""]}, + "accountUnexpire --account \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/groupCreate b/bin/plugin/restricted/groupCreate new file mode 100755 index 0000000..5cc5c68 --- /dev/null +++ b/bin/plugin/restricted/groupCreate @@ -0,0 +1,151 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ReadKey; +use Term::ANSIColor; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "create a new group", + options => { + "group=s" => \my $group, + "owner=s" => \my $owner, + "algo=s" => \my $algo, + "size=i" => \my $size, + "encrypted" => \my $encrypted, + "no-key" => \my $no_key, + }, + help => \&help, +); + +sub help { + require Term::ANSIColor; + my $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'egress'); + my @algoList = @{$fnret->value}; + my $algos = Term::ANSIColor::colored(uc join(' ', @algoList), 'green'); + my $helpAlgoSize = '--algo rsa --size 4096'; + if (grep { $_ eq 'ecdsa' } @algoList) { + $helpAlgoSize = '--algo ecdsa --size 521'; + } + if (grep { $_ eq 'ed25519' } @algoList) { + $helpAlgoSize = '--algo ed25519'; + } + osh_info <<"EOF"; +Usage : --osh SCRIPT_NAME --group GROUP --owner ACCOUNT $helpAlgoSize [--encrypted] [--no-key] +Description : creates group GROUP on the bastion with ACCOUNT as the owner +Params : + + --group Group name to create + + --owner Preexisting bastion account to assign as owner (can be you) + + --encrypted Add a passphrase to the key. Beware that you'll have to enter it for each use. + Do NOT add the passphrase after this option, you'll be prompted interactively for it. + + --algo Specifies the algo of the key, either rsa, ecdsa or ed25519. + --size Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (4096 is good). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + + --no-key Don't generate an egress SSH key at all for this group + +With the policy and SSH version on this bastion, +the following algorithms are supported: $algos + +algo size strength speed compatibility +------- ---- ---------- -------- ----------------------- +RSA 4096 good slow works everywhere +ECDSA 521 strong fast debian7+ (OpenSSH 5.7+) +ED25519 256 verystrong veryfast debian8+ (OpenSSH 6.5+) +EOF + return 0; +} + +# +# code +# +my $fnret; + +# +# params check +# + +if (!$group || !$owner) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Group name or owner is missing"; +} + +if ($algo && !$size && lc($algo) eq 'ed25519') { + $size = 256; # ed25519 size is always 256 +} + +if (!$no_key && (!$algo || !$size)) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Group algorithm or size is missing"; +} + +$fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +# check if algo is supported by system +if ($algo) { + $algo = lc($algo); + $fnret = OVH::Bastion::is_allowed_algo_and_size(algo => $algo, size => $size, way => 'egress'); + $fnret or osh_exit $fnret; +} + +# +# Now create it +# + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupCreate'; +push @command, "--group", $group, "--owner", $owner; +push @command, "--algo", $algo if $algo; +push @command, "--size", $size if $size; +push @command, "--encrypted" if $encrypted; +push @command, "--no-key" if $no_key; + +ReadMode('noecho'); +$fnret = OVH::Bastion::helper(cmd => \@command, expects_stdin => 1); +ReadMode('restore'); +$fnret or osh_exit $fnret; + +my $result_hash = $fnret->value; + +if ($no_key) { + osh_info 'Group creation complete!'; +} +else { + osh_info 'Group creation complete! The public key of this group is:'; + $fnret = OVH::Bastion::get_bastion_ips(); + my $from; + if ($fnret) { + my @ips = @{$fnret->value}; + $from = 'from="' . join(',', @ips) . '"'; + } + + $fnret = OVH::Bastion::get_group_keys(group => $group); + if ($fnret and $from) { + foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + $key->{'prefix'} = $from; + OVH::Bastion::print_public_key(key => $key); + $result_hash->{'public_key'} = $key; + } + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/restricted/groupCreate.json b/bin/plugin/restricted/groupCreate.json new file mode 100644 index 0000000..87f40c3 --- /dev/null +++ b/bin/plugin/restricted/groupCreate.json @@ -0,0 +1,16 @@ +{ + "interactive": [ + "groupCreate" , {"ac" : ["--group"]}, + "groupCreate --group" , {"pr" : [""]}, + "groupCreate --group \\S+" , {"ac" : ["--owner"]}, + "groupCreate --group \\S+ --owner" , {"ac" : [""]}, + "groupCreate --group \\S+ --owner \\S+" , {"ac" : ["--algo"]}, + "groupCreate --group \\S+ --owner \\S+ --algo" , {"ac" : ["rsa", "ecdsa", "ed25519"]}, + "groupCreate --group \\S+ --owner \\S+ --algo \\S+" , {"ac" : ["--size"]}, + "groupCreate --group \\S+ --owner \\S+ --algo \\S+ --size" , {"pr" : [""]}, + "groupCreate --group \\S+ --owner \\S+ --algo \\S+ --size \\d+" , {"ac" : ["", "--encrypted"]}, + "groupCreate --group \\S+ --owner \\S+ --algo \\S+ --size \\d+ --encrypted" , {"pr" : [""]} + ], + "master_only": true, + "terminal_mode": "raw" +} diff --git a/bin/plugin/restricted/groupDelete b/bin/plugin/restricted/groupDelete new file mode 100755 index 0000000..e7cde2f --- /dev/null +++ b/bin/plugin/restricted/groupDelete @@ -0,0 +1,74 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# TODO move this from restricted/ to group-owner/ + +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "delete an existing bastion group", + options => { + 'group=s' => \my $group, + 'no-confirm' => \my $noConfirm, + }, + helptext => <<'EOF', +Delete a group + +Usage: --osh SCRIPT_NAME --group GROUP + + --group GROUP Group name to delete + --no-confirm Skip group name confirmation, but blame yourself if you deleted the wrong group! +EOF +); + +# +# code +# +my $fnret; + +# +# params check +# + +if (!$group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'group' parameter"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +if (!$noConfirm) { + osh_info <<'EOS'; +!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! +!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! +!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! + +You are about to DELETE a bastion group, to be sure you're not drunk, +please type the name of the group you want to delete (won't be echoed): + +EOS + my $sentence = ; + ## use critic + chomp $sentence; + + if ($sentence ne $shortGroup) { + osh_exit 'ERR_OPERATOR_IS_DRUNK', "You're drunk, apparently, aborted."; + } +} + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupDelete'; +push @command, "--group", $group; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/groupDelete.json b/bin/plugin/restricted/groupDelete.json new file mode 100644 index 0000000..dd3c28a --- /dev/null +++ b/bin/plugin/restricted/groupDelete.json @@ -0,0 +1,9 @@ +{ + "interactive": [ + "groupDelete" , {"ac" : ["--group"]}, + "groupDelete --group" , {"ac" : [""]}, + "groupDelete --group \\S+" , {"pr" : ""} + ], + "master_only": true, + "terminal_mode": "raw" +} diff --git a/bin/plugin/restricted/realmCreate b/bin/plugin/restricted/realmCreate new file mode 100755 index 0000000..217f2e5 --- /dev/null +++ b/bin/plugin/restricted/realmCreate @@ -0,0 +1,91 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "declare a new trusted realm on this bastion", + options => { + 'realm=s' => \my $realm, + 'public-key=s' => \my $pubKey, + 'from=s' => \my $from, + 'comment=s' => \my $comment, + }, + helptext => <<'EOF', +Declare and create a new trusted realm + +Usage: --osh SCRIPT_NAME --realm REALM [OPTIONS] + + --realm REALM Realm name to create + --comment STRING An optional comment when creating the realm. Double-quote if you're under a shell. + --from IP1,IP2 Comma-separated list of outgoing IPs used by the realm we're declaring (i.e. IPs used by the bastion(s) on the other side) + the expected format is the one used by the from="" directive on SSH keys (IP and prefixes are supported) + --public-key KEY Public SSH key to deposit on the bastion to access this realm. If not present, + you'll be prompted interactively for it. Use double-quoting if your're under a shell. +EOF +); + +# ugly hack for space-enabled parameter +# XXX should be removed, double quoting fixes the problem, but keep it for compatibility +if (ref $remainingOptions eq 'ARRAY' and @$remainingOptions) { + $pubKey .= " " . join(" ", @$remainingOptions); +} + +my $fnret; + +# params check +if (not $realm or not $from) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'realm' or 'from'"; +} + +my $account = "realm_$realm"; +$fnret = OVH::Bastion::is_account_valid(account => $account, accountType => "realm"); +$fnret or osh_exit $fnret; + +$fnret = OVH::Bastion::is_account_existing(account => $account); +if ($fnret) { + osh_exit 'ERR_ALREADY_EXISTING', "This realm already exists"; +} + +# TODO check $from + +if (!$pubKey) { + $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => 'ingress'); + $fnret or osh_exit $fnret; + my @algoList = @{$fnret->value}; + my $algos = join(' ', @algoList); + osh_info "Please paste the SSH key you want to add. This bastion supports the following algorithms:\n"; + if (grep { 'ed25519' eq $_ } @algoList) { + osh_info "ED25519: strongness[#####] speed[#####], use `ssh-keygen -t ed25519' to generate one"; + } + if (grep { 'ecdsa' eq $_ } @algoList) { + osh_info "ECDSA : strongness[####.] speed[#####], use `ssh-keygen -t ecdsa -b 521' to generate one"; + } + if (grep { 'rsa' eq $_ } @algoList) { + osh_info "RSA : strongness[###..] speed[#....], use `ssh-keygen -t rsa -b 4096' to generate one"; + } + osh_info "\nThis should be the egress key of the group named 'realm' from the other side (your paste won't be echoed)."; + $pubKey = ; + ## use critic +} + +$fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress'); +$fnret or osh_exit $fnret; + +# +# Now create it +# +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountCreate'; +push @command, "--type", "realm", "--account", $realm, "--pubKey", $pubKey, "--always-active", "--uid-auto"; +push @command, "--from", $from if $from; +push @command, "--comment", $comment if $comment; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/realmCreate.json b/bin/plugin/restricted/realmCreate.json new file mode 100644 index 0000000..22a0148 --- /dev/null +++ b/bin/plugin/restricted/realmCreate.json @@ -0,0 +1,3 @@ +{ + "master_only": true +} diff --git a/bin/plugin/restricted/realmDelete b/bin/plugin/restricted/realmDelete new file mode 100755 index 0000000..28651f1 --- /dev/null +++ b/bin/plugin/restricted/realmDelete @@ -0,0 +1,67 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "delete an existing bastion account", + options => { + 'realm=s' => \my $wantedRealm, + }, + helptext => <<'EOF', +Delete a bastion realm + +Usage: --osh SCRIPT_NAME --realm REALM + + --realm REALM Name of the realm to delete +EOF +); + +# +# code +# +my $fnret; + +# +# params check +# +if (!$wantedRealm) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing 'realm' parameter"; +} + +my $pristineRealm = $wantedRealm; +$wantedRealm = "realm_$wantedRealm"; +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $wantedRealm, accountType => "realm"); +$fnret or osh_exit $fnret; +$wantedRealm = $fnret->value->{'account'}; # untaint + +osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; +osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; +osh_info "!!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!! WARNING !!!!"; +osh_info " "; + +osh_info "You are about to DELETE a bastion realm, to be sure you're not drunk, type the following sentence:"; +osh_info " "; +osh_info ' "Yes, do as I say and delete , kthxbye" '; +osh_info " "; +my $sentence = ; +chomp $sentence; + +if ($sentence ne "Yes, do as I say and delete $pristineRealm, kthxbye") { + osh_exit 'ERR_OPERATOR_IS_DRUNK', "You're drunk, apparently, aborted."; +} +osh_info "OK, proceeding..."; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountDelete'; +push @command, '--type', 'realm', '--account', $wantedRealm; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/realmDelete.json b/bin/plugin/restricted/realmDelete.json new file mode 100644 index 0000000..b11f1e6 --- /dev/null +++ b/bin/plugin/restricted/realmDelete.json @@ -0,0 +1,8 @@ +{ + "interactive": [ + "realmDelete" , {"ac" : ["--realm"]}, + "realmDelete --realm" , {"ac" : [""]}, + "realmDelete --realm \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/realmInfo b/bin/plugin/restricted/realmInfo new file mode 100755 index 0000000..de491ce --- /dev/null +++ b/bin/plugin/restricted/realmInfo @@ -0,0 +1,54 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "realm information", + options => {'realm=s' => \my $pRealm}, + helptext => <<'EOF', +Display informations about a bastion realm + +Usage: --osh SCRIPT_NAME --realm REALM + + --realm REALM Name of the realm to show info about +EOF +); + +my $fnret; + +if (!$pRealm) { + help(); + osh_exit R('ERR_MISSING_PARAMETER', msg => "Missing argument 'realm'"); +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => "realm_$pRealm", accountType => "realm"); +$fnret or osh_exit $fnret; + +$fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $pRealm); +$fnret or osh_exit $fnret; + +my @accounts = sort @{$fnret->value}; +my %details; + +if (@accounts) { + osh_info "The following accounts from realm $pRealm are known:"; + foreach my $account (@accounts) { + $fnret = OVH::Bastion::get_acls(account => "$pRealm/$account"); + my $nb = $fnret ? scalar(@{$fnret->value}) : "?"; + $details{$account} = {accesses => ($fnret ? scalar(@{$fnret->value}) : undef)}; + osh_info sprintf("- %-18s [%s accesses]", $account, $nb); + } + osh_info "\nTo get their access list, use --osh accountListAccesses --account $pRealm/account_name_here"; +} +else { + osh_info "No remote accounts from realm $pRealm have accesses yet."; +} + +osh_ok({realm => $pRealm, accounts => \@accounts, account_details => \%details}); diff --git a/bin/plugin/restricted/realmInfo.json b/bin/plugin/restricted/realmInfo.json new file mode 100644 index 0000000..1f2cd79 --- /dev/null +++ b/bin/plugin/restricted/realmInfo.json @@ -0,0 +1,7 @@ +{ + "interactive": [ + "realmInfo" , {"ac" : ["--realm"]}, + "realmInfo --realm" , {"ac" : [""]}, + "realmInfo --realm \\S+" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/realmList b/bin/plugin/restricted/realmList new file mode 100755 index 0000000..f7d5010 --- /dev/null +++ b/bin/plugin/restricted/realmList @@ -0,0 +1,47 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "list bastion realms", + options => { + "realm=s" => \my $pRealm, + }, + helptext => <<'EOF', +List the bastions realms + +Usage: --osh SCRIPT_NAME [--realm REALM] + + --realm REALM Only list the specified realm (mainly: check if it exists) +EOF +); + +my $fnret; +if ($pRealm) { + $pRealm =~ s{^realm_}{}; + $fnret = OVH::Bastion::get_realm_list(realms => [$pRealm]); +} +else { + $fnret = OVH::Bastion::get_realm_list(); +} +$fnret or osh_exit $fnret; +my $realms = $fnret->value; + +my $result_hash = {}; +foreach my $realm (sort keys %$realms) { + $result_hash->{$realm}{'name'} = $realm; + + osh_info $realm; +} +if (not %$realms) { + osh_info "No realm found"; +} + +osh_ok $result_hash; diff --git a/bin/plugin/restricted/rootListIngressKeys b/bin/plugin/restricted/rootListIngressKeys new file mode 100755 index 0000000..abac447 --- /dev/null +++ b/bin/plugin/restricted/rootListIngressKeys @@ -0,0 +1,46 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor qw{ colored }; +use POSIX qw{ strftime }; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "public keys to connect as root on this bastion", + options => {}, + helptext => <<'EOF', +List the public keys to connect as root on this bastion + +Usage: --osh SCRIPT_NAME + +This command is mainly useful for auditability purposes. +As it gives some information as to who can be root on the underlying system, +please grant this command only to accounts that need to have this information. +EOF +); + +my $fnret; + +my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountListIngressKeys'; +push @command, '--account', 'root'; + +$fnret = OVH::Bastion::helper(cmd => \@command); +$fnret or osh_exit $fnret; + +my @result; +foreach my $key (@{$fnret->value || []}) { + OVH::Bastion::print_public_key(key => $key, id => $key->{'index'}, err => $key->{'err'}); + $key->{'validity'} = delete $key->{'err'}; + $key->{'id'} = delete $key->{'index'}; + $key->{'from_list'} = delete $key->{'fromList'}; + push @result, $key; +} + +osh_ok({keys => \@result, account => 'root'}); diff --git a/bin/plugin/restricted/rootListIngressKeys.json b/bin/plugin/restricted/rootListIngressKeys.json new file mode 100644 index 0000000..de71571 --- /dev/null +++ b/bin/plugin/restricted/rootListIngressKeys.json @@ -0,0 +1,5 @@ +{ + "interactive": [ + "rootListIngressKeys" , {"pr" : [""]} + ] +} diff --git a/bin/plugin/restricted/selfAddPersonalAccess b/bin/plugin/restricted/selfAddPersonalAccess new file mode 100755 index 0000000..a94afd2 --- /dev/null +++ b/bin/plugin/restricted/selfAddPersonalAccess @@ -0,0 +1,126 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "adding private access to a server on your account", + options => { + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + "force-key=s" => \my $forceKey, + "force" => \my $force, + "ttl=s" => \my $ttl, + "comment=s" => \my $comment, + }, + helptext => <<'EOF', +Remove a personal server access from an account + +Usage: --osh SCRIPT_NAME --host HOST [OPTIONS] + + --host IP|HOST|IP/MASK Server to add access to + --user USER Remote login to use, if you want to allow any login, use --user-any + --user-any Allow access with any remote login + --port PORT Remote SSH port to use, if you want to allow any port, use --port-any + --port-any Allow access to all remote ports + --scpup Allow SCP upload, you--bastion-->server (omit --user in this case) + --scpdown Allow SCP download, you<--bastion--server (omit --user in this case) + --force Add the access without checking that the public SSH key is properly installed remotely + --force-key FINGERPRINT Only use the key with the specified fingerprint to connect to the server (cf selfListEgressKeys) + --ttl SECONDS|DURATION Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + --comment "'ANY TEXT'" Add a comment alongside this server. Quote it twice as shown if you're under a shell. +EOF +); + +my $fnret; + +if (!$ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter 'host' or didn't resolve correctly"; +} + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --user and --user-any, please check what you're doing"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + osh_warn "You didn't specify --user or --user-any, defaulting to --user-any, this will no longer be implicit in future versions"; + $userAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to allow any remote user, use --user-any"; +} + +if ($port and $portAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --port and --port-any, please check what you're doing"; +} +if (not $port and not $portAny) { + osh_warn "You didn't specify --port or --port-any, defaulting to --port-any, this will no longer be implicit in future versions"; + $portAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to allow any remote port, use --port-any"; +} + +if (defined $ttl) { + $fnret = OVH::Bastion::is_valid_ttl(ttl => $ttl); + $fnret or osh_exit $fnret; + $ttl = $fnret->value->{'seconds'}; +} + +if ($forceKey) { + $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $forceKey); + $fnret or osh_exit $fnret; + $forceKey = $fnret->value->{'fingerprint'}; +} + +if (not $force) { + $fnret = OVH::Bastion::ssh_test_access_way(account => $self, user => $user, port => $port, ip => $ip, forceKey => $forceKey); + if ($fnret->is_ok and $fnret->err ne 'OK') { + + # we have something to say, say it + osh_info $fnret->msg; + } + elsif (not $fnret) { + osh_info "Note: if you still want to add this access even if it doesn't work, use --force"; + osh_exit $fnret; + } +} +else { + osh_info "Forcing add as asked, we didn't test the SSH connection, maybe it won't work!"; +} + +my @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess'; +push @command, '--target', 'self'; +push @command, '--action', 'add'; +push @command, '--account', $self; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; +push @command, '--force-key', $forceKey if $forceKey; +push @command, '--ttl', $ttl if $ttl; +push @command, '--comment', $comment if $comment; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/selfAddPersonalAccess.json b/bin/plugin/restricted/selfAddPersonalAccess.json new file mode 100644 index 0000000..a1da2cf --- /dev/null +++ b/bin/plugin/restricted/selfAddPersonalAccess.json @@ -0,0 +1,20 @@ +{ + "interactive": [ + "selfAddPersonalAccess" , {"ac" : ["--host"]}, + "selfAddPersonalAccess --host" , {"pr" : ["", "", ""]}, + "selfAddPersonalAccess --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "selfAddPersonalAccess --host \\S+ .*--user" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ .*--port" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "selfAddPersonalAccess --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"ac" : ["--force-key","--ttl",""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl \\S+" , {"ac" : ["--force-key",""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --ttl \\S+ --force-key" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+" , {"ac" : ["--ttl",""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+ --ttl" , {"pr" : [""]}, + "selfAddPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+ --force-key \\S+ --ttl \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/selfDelPersonalAccess b/bin/plugin/restricted/selfDelPersonalAccess new file mode 100755 index 0000000..bde8603 --- /dev/null +++ b/bin/plugin/restricted/selfDelPersonalAccess @@ -0,0 +1,85 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "removing personal access to a server from an account", + options => { + "user-any" => \my $userAny, + "port-any" => \my $portAny, + "scpup" => \my $scpUp, + "scpdown" => \my $scpDown, + }, + helptext => <<'EOF', +Remove a personal server access from your account + +Usage: --osh SCRIPT_NAME --host HOST [OPTIONS] + + --host IP|HOST|IP/MASK Server to remove access from + --user USER Remote user that was allowed, if any user was allowed, use --user-any + --user-any Use if any remote login was allowed + --port PORT Remote SSH port that was allowed, if any port was allowed, use --port-any + --port-any Use if any remote port was allowed + --scpup Remove SCP upload right, you--bastion-->server (omit --user in this case) + --scpdown Remove SCP download right, you<--bastion--server (omit --user in this case) +EOF +); + +if (!$ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter 'host' or didn't resolve correctly"; +} + +if ($user and $userAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --user and --user-any, please check what you're doing"; +} +if ($scpUp and $scpDown) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --scpup and --scpdown, if you want to grant both, please do it in two separate commands"; +} +if (($scpUp or $scpDown) and ($user or $userAny)) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', +"To grant SCP access, first ensure SSH access is granted to the machine (with the --user you need, or --user-any), then grant with --scpup and/or --scpdown, omitting --user/--user-any"; +} +$user = '!scpupload' if $scpUp; +$user = '!scpdownload' if $scpDown; + +if (not $user and not $userAny) { + osh_warn "You didn't specify --user or --user-any, defaulting to --user-any, this will no longer be implicit in future versions"; + $userAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No user specified, if you want to allow any remote user, use --user-any"; +} + +if ($port and $portAny) { + help(); + osh_exit 'ERR_INCOMPATIBLE_PARAMETERS', "You specified both --port and --port-any, please check what you're doing"; +} +if (not $port and not $portAny) { + osh_warn "You didn't specify --port or --port-any, defaulting to --port-any, this will no longer be implicit in future versions"; + $portAny = 1; + + #help(); + #osh_exit 'ERR_MISSING_PARAMETER', "No port specified, if you want to allow any remote port, use --port-any"; +} + +my @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; +push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountModifyPersonalAccess'; +push @command, '--target', 'self'; +push @command, '--action', 'del'; +push @command, '--account', $self; +push @command, '--ip', $ip; +push @command, '--user', $user if $user; +push @command, '--port', $port if $port; + +osh_exit OVH::Bastion::helper(cmd => \@command); diff --git a/bin/plugin/restricted/selfDelPersonalAccess.json b/bin/plugin/restricted/selfDelPersonalAccess.json new file mode 100644 index 0000000..6af8e82 --- /dev/null +++ b/bin/plugin/restricted/selfDelPersonalAccess.json @@ -0,0 +1,13 @@ +{ + "interactive": [ + "selfDelPersonalAccess" , {"ac" : ["--host"]}, + "selfDelPersonalAccess --host" , {"pr" : ["", "", ""]}, + "selfDelPersonalAccess --host \\S+" , {"ac" : ["", "--user", "--port"]}, + "selfDelPersonalAccess --host \\S+ .*--user" , {"pr" : [""]}, + "selfDelPersonalAccess --host \\S+ .*--port" , {"pr" : [""]}, + "selfDelPersonalAccess --host \\S+ --user \\S+" , {"ac" : ["", "--port"]}, + "selfDelPersonalAccess --host \\S+ --port \\S+" , {"ac" : ["", "--user"]}, + "selfDelPersonalAccess --host \\S+ --(port|user) \\S+ --(port|user) \\S+" , {"pr" : [""]} + ], + "master_only": true +} diff --git a/bin/plugin/restricted/whoHasAccessTo b/bin/plugin/restricted/whoHasAccessTo new file mode 100755 index 0000000..f564b7e --- /dev/null +++ b/bin/plugin/restricted/whoHasAccessTo @@ -0,0 +1,134 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Term::ANSIColor; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); + +my (@ignoreGroups, $ignorePersonal, $showWildcards); +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "who has access to this?", + options => { + "ignore-group=s" => \@ignoreGroups, + "ignore-private" => \$ignorePersonal, # deprecated name + "ignore-personal" => \$ignorePersonal, + "show-wildcards" => \$showWildcards, + }, + helptext => <<'EOF', +List the accounts that have access to a given server + +Usage: --osh SCRIPT_NAME --host SERVER [OPTIONS] + + --host SERVER List declared accesses to this server + --user USER Remote user allowed (if not specified, ignore user specifications) + --port PORT Remote port allowed (if not specified, ignore port specifications) + --ignore-personal Don't check accounts' personal accesses (i.e. only check groups) + --ignore-group GROUP Ignore accesses by this group, if you know GROUP public key is in fact + not present on remote server but bastion thinks it is + --show-wildcards Also list accesses that match because 0.0.0.0/0 is listed in a group or private access, + this is disabled by default because this is almost always just noise (see Note below) + +Note: This list is what the bastion THINKS is true, which means that if some group has 0.0.0.0/0 in its list, +then it'll show all the members of that group as having access to the machine you're specifying, through this group key. +This is only true if the remote server does have the group key installed, of course, which the bastion +can't tell without trying to connect "right now" (which it won't do). +EOF +); + +# +# code +# + +my $fnret; + +if (!$ip) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing parameter host (or it didn't resolve correctly)"; +} + +$fnret = OVH::Bastion::get_account_list(); +$fnret or osh_exit $fnret; + +osh_warn "IMPORTANT: read the --help of this command to understand what is shown here and why."; +osh_info " "; + +my $result_hash = {}; + +sub process_account { + my %params = @_; + my $account = $params{'account'}; + + $fnret = OVH::Bastion::is_access_granted( + account => $account, + user => $user, + ip => $ip, + ipfrom => $ENV{'OSH_IP_FROM'}, + port => $port, + cache => 1, + ignorePort => ($port ? 0 : 1), # return accesses without checking for the specified port + ignoreUser => ($user ? 0 : 1), # return accesses without checking for the specified remote user + ); + if ($fnret) { + my $byPersonal = 0; + my %byGroups = (); + foreach my $access (@{$fnret->value || []}) { + if (not $showWildcards and $access->{'size'} == 2**32) { + next; + } + if ($access->{'type'} =~ /^personal/ and not $ignorePersonal) { + $byPersonal++; + } + elsif ($access->{'type'} =~ /^group/ and not grep { $access->{'group'} eq $_ } @ignoreGroups) { + $byGroups{$access->{'group'} . '(' . $access->{'type'} . ')'} = 1; + } + } + + my @shortGroups = sort keys %byGroups; + if ($byPersonal and keys %byGroups) { + osh_info "$account has access by his " . colored('personal', 'red') . " key and by the following groups: " . colored(join(' ', @shortGroups), "green"); + $result_hash->{$account} = {personal_access => 1, group_access => [sort keys %byGroups]}; + } + elsif ($byPersonal) { + osh_info "$account has access by his " . colored('personal', 'red') . " key only"; + $result_hash->{$account} = {personal_access => 1, group_access => []}; + } + elsif (keys %byGroups) { + osh_info "$account has access by the following groups: " . colored(join(' ', @shortGroups), "green"); + $result_hash->{$account} = {personal_access => 0, group_access => [sort keys %byGroups]}; + } + else { + ; #osh_info "$account has access (I don't know how!!?)"; + } + } + + return 1; +} + +my @accounts = sort keys %{$fnret->value}; +foreach my $account (@accounts) { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, localOnly => 1); + if (!$fnret) { + + # maybe it's a realm shared account ? + my ($realm) = $account =~ /^realm_(.+)$/; + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => "realm"); + $fnret or next; + + # it is. ok, get all the known remote accounts + $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $account); + $fnret or next; + foreach my $remoteaccount (@{$fnret->value || []}) { + process_account(account => "$realm/$remoteaccount"); + } + } + else { + process_account(account => $account); + } +} + +osh_ok $result_hash; diff --git a/bin/plugin/restricted/whoHasAccessTo.json b/bin/plugin/restricted/whoHasAccessTo.json new file mode 100644 index 0000000..b03fcaa --- /dev/null +++ b/bin/plugin/restricted/whoHasAccessTo.json @@ -0,0 +1,10 @@ +{ + "interactive": [ + "whoHasAccessTo" , {"ac" : ["--host"]}, + "whoHasAccessTo --host" , {"pr" : [""]}, + "whoHasAccessTo --host \\S+ (.*--(user|port|ignore-group) \\S+| .*--(ignore-wildcard|ignore-private))?$" , {"ac" : ["--user","--port","--ignore-wildcard","--ignore-private","--ignore-group",""]}, + "whoHasAccessTo --host \\S+ .*--user" , {"pr" : [""]}, + "whoHasAccessTo --host \\S+ .*--port" , {"pr" : [""]}, + "whoHasAccessTo --host \\S+ .*--ignore-group" , {"ac" : [""]} + ] +} diff --git a/bin/proxy/osh-http-proxy-daemon b/bin/proxy/osh-http-proxy-daemon new file mode 100755 index 0000000..12b3664 --- /dev/null +++ b/bin/proxy/osh-http-proxy-daemon @@ -0,0 +1,91 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Bastion; +use OVH::Bastion::ProxyHTTP; + +$ENV{'FORCE_STDERR'} = 1; +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; + +my $fnret = OVH::Bastion::load_configuration_file( + file => OVH::Bastion::main_configuration_directory() . "/osh-http-proxy.conf", + secure => 1, + keywords => [qw{ enabled port ssl_key ssl_certificate }], +); +if (not $fnret) { + osh_warn "Error loading configuration: " . $fnret->msg; + exit 1; +} + +my $config = $fnret->value(); + +# config check + +if (not $config->{'enabled'}) { + osh_warn "The HTTP Proxy is disabled by configuration"; + exit 0; # exit with a success so that systemd doesn't try to restart us +} + +$fnret = OVH::Bastion::is_valid_port(port => $config->{'port'}); +if (!$fnret) { + osh_warn "Bad configuration: " . $fnret->msg; + exit 1; +} + +my %options; +if ($config->{'ciphers'}) { + $options{'SSL_cipher_list'} = $config->{'ciphers'}; + $options{'SSL_honor_cipher_order'} = 1; +} + +my $_normalize_config_integer = sub { + my ($param, $min, $max, $default) = @_; + if (!defined $config->{$param} || $config->{$param} !~ /^\d+$/ || $config->{$param} < $min || $config->{$param} > $max) { + $config->{$param} = $default; + } +}; +$_normalize_config_integer->('timeout', 1, 3600, 120); +$_normalize_config_integer->('min_servers', 1, 512, 8); +$_normalize_config_integer->('max_servers', 1, 512, 32); +$_normalize_config_integer->('min_spare_servers', 1, 512, 8); +$_normalize_config_integer->('max_spare_servers', 1, 512, 16); + +foreach my $file ($config->{'ssl_key'}, $config->{'ssl_certificate'}) { + if (!(-r -f $file)) { + osh_warn "Bad configuration: file '$file' doesn't exist, is not readable, or is not a file"; + exit 1; + } +} + +OVH::Bastion::ProxyHTTP->new()->run( + %options, + port => $config->{'port'} . '/ssl', + SSL_key_file => $config->{'ssl_key'}, + SSL_cert_file => $config->{'ssl_certificate'}, + ipv => 4, + server_type => 'PreFork', + max_requests => 1, # DO NOT TOUCH, anything else that this seems to mix requests/answers (!?) + min_servers => $config->{'min_servers'}, + max_servers => $config->{'max_servers'}, + min_spare_servers => $config->{'min_spare_servers'}, + max_spare_servers => $config->{'max_spare_servers'}, + access_log_file => "/home/proxyhttp/access.log", + + # This is the max time allowed for http_process_request(), which is where we spawn our worker. + # This value is defined when the proxy starts and is only applicable to the master process, hence it can't be + # modified afterwards with X-Bastion-Timeout. + # So we set it to the max value allowed for X-Bastion-Timeout, + # which is also the max allowed value of the 'timeout' config param (see above). + timeout_idle => 3600, + proxy_config => { + insecure => $config->{'insecure'} ? 1 : 0, + timeout => $config->{'timeout'}, # our worker will wait for up to this amount of time for the egress connection to complete + }, +) or die "Proxy launch failed!"; + +# not reachable +osh_warn "Proxy exited!?"; +exit 1; diff --git a/bin/proxy/osh-http-proxy-worker b/bin/proxy/osh-http-proxy-worker new file mode 100755 index 0000000..90f8d1d --- /dev/null +++ b/bin/proxy/osh-http-proxy-worker @@ -0,0 +1,426 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# FILEMODE 0755 +# FILEOWN root root +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; + +use Fcntl qw(:flock SEEK_END); +use Getopt::Long; +use HTTP::Message; +use IO::Pipe; +use IO::Select; +use IO::Socket::SSL; +use IO::Socket::SSL; +use LWP::UserAgent; +use MIME::Base64; +use POSIX (); +use Storable qw{ freeze thaw }; +use Time::HiRes (); + +$ENV{'FORCE_STDERR'} = 1; +$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; + +my $uniqid; +my $fnret; +my %log_params = ( + 'cmdtype' => 'proxyhttp_worker', + 'uniq_id' => $uniqid, + 'bastionip' => $ENV{'SERVER_ADDR'}, + 'bastionport' => $ENV{'SERVER_PORT'}, + 'ipfrom' => $ENV{'REMOTE_ADDR'}, + 'portfrom' => $ENV{'REMOTE_PORT'}, + 'custom' => [[user_agent => $ENV{'HTTP_USER_AGENT'}]], +); + +# to handle child timeout +my $child_finished = 0; +$SIG{'CHLD'} = sub { wait; $child_finished = 1 }; + +my @headers; + +sub log_and_exit { + my ($code, $msg, $body, $params) = @_; + + my %merged = (%log_params, %$params); + + $merged{'allowed'} //= 0; + + # custom data will only be logged to logfile and syslog, not sql (it's not in the generic schema) + push @{$merged{'custom'}}, ['code' => $code], ['msg' => $msg]; + OVH::Bastion::log_access_insert(%merged); + + push @headers, ["X-Bastion-Local-Status" => $code]; + HEXIT('OK', value => {code => $code, msg => $msg, body => $body . "\n", headers => \@headers, allowed => $merged{'allowed'}}); +} + +my $pass = delete $ENV{'PROXY_ACCOUNT_PASSWORD'}; +my $content; + +GetOptions( + "account=s" => \my $account, + "user=s" => \my $user, + "group=s" => \my $group, + "context=s" => \my $context, + "host=s" => \my $remotemachine, + "port=i" => \my $remoteport, + "method=s" => \my $method, + "path=s" => \my $path, + "header=s" => \my @client_headers, + "timeout=i" => \my $timeout, + "insecure" => \my $insecure, + "uniqid=s" => \$uniqid, + "post-data-stdin" => \my $postDataStdin, + "allow-downgrade" => \my $allow_downgrade, + "monitoring" => \my $monitoring, +); +push @headers, ["X-Bastion-Remote-IP" => $remotemachine]; + +if (!$postDataStdin) { + $content = delete $ENV{'PROXY_POST_DATA'}; + $content = decode_base64($content) if $content; +} +else { + local $/ = undef; + $content = ; +} +push @headers, ["X-Bastion-Request-Length" => "" . length($content)]; + +# if we're being called by the monitoring, just exit happily +if ($monitoring) { + HEXIT('OK', value => {code => 200, msg => 'OK', body => $OVH::Bastion::VERSION, allowed => 1}); +} + +$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); # time: 20ms +$fnret or log_and_exit(400, "Bad Request (bad account)", "Account name is invalid", {comment => "invalid_account"}); +$account = $fnret->value->{'account'}; # untaint +$log_params{'account'} = $account; +$log_params{'user'} = $user; +$log_params{'hostto'} = $remotemachine; +$log_params{'params'} = $path; +$log_params{'plugin'} = uc($method); +push @{$log_params{'custom'}}, ['post_length' => length($content)]; + +my $shortGroup; +if ($group) { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or log_and_exit(400, "Bad Request (bad group)", "Group name is invalid", {comment => "invalid_group"}); + $shortGroup = $fnret->value->{'shortGroup'}; + $group = $fnret->value->{'group'}; +} + +if (!OVH::Bastion::is_valid_port(port => $remoteport)) { + log_and_exit(400, "Bad Request (bad port number)", "Port number is out of range", {comment => "invalid_port_number"}); +} + +$log_params{'portto'} = $remoteport; + +if (!$timeout || $timeout !~ /^\d+$/ || $timeout < 1) { + $timeout = 10; +} +elsif ($timeout > 3600) { + $timeout = 3600; +} + +if (not $pass) { + log_and_exit(400, "Bad Request (no password)", "No password", {comment => "missing_password"}); +} + +# convert the remotemachine into an IP if needed +# if: avoid loading Net::IP and BigInt if there's no host specified +if ($remotemachine !~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) { + $fnret = OVH::Bastion::get_ip(host => $remotemachine); + if ($fnret && $fnret->value->{'ip'} =~ /^([0-9.:]+)$/) { + $remotemachine = $1; # untaint + } + else { + log_and_exit(400, "Bad Request (host not resolved)", "Specified remote host couldn't be resolved through the DNS", {comment => "host_not_found"}); + } +} +else { + # it's already an IP, get $1 for untaint + $remotemachine = $1; +} + +delete $log_params{'hostto'}; +$log_params{'ipto'} = $remotemachine; + +# now check that the password we were given for account matches the hash we have stored for it +# first get our stored hash +$fnret = OVH::Bastion::account_config(account => $account, key => "proxyhttphash"); +if (not $fnret or not $fnret->value) { + + # bad login because we couldn't open the proper file + log_and_exit(403, "Access Denied", "No password configured for you, $account", {comment => "no_password_for_login"}); +} +my $storedhash = $fnret->value; +chomp $storedhash; + +# extract the salt from the stored hash we have +if ($storedhash !~ /^\$(?[a-zA-Z0-9]{1,2})\$(?[^\$]+)\$[^\$]+$/) { + + # the hash we have stored in the bastion is fucked :( + log_and_exit(500, "Internal Error (malformed hash)", "Please contact a bastion admin\n", {comment => "malformed_hash"}); +} + +if ($storedhash ne crypt($account . ':' . $pass, '$' . $+{'cipher'} . '$' . $+{'salt'})) { + log_and_exit(403, "Access Denied", "Incorrect username ($account) or password (#REDACTED#, length=" . length($pass) . ")", {comment => "bad_login_password"}); +} +undef $pass; +undef $storedhash; + +# read the password we must use +# pseudoalgo: +# if mode was explicitely specified to group, we look for a group password +# elif mode was explicitely specified to self, we look for a self account password +# elif the legacy file with the same name as $user exists in /home/passkeeper, use it +# elif the specified $user happens to be a group name, look for this group password +# else look for a self account password + +my $authmode; +my $hint; +if ($context eq 'group') { + $hint = $shortGroup; + $authmode = 'group/explicit'; +} +elsif ($context eq 'self') { + $hint = $account; + $authmode = 'self/explicit'; +} +elsif (-f "/home/passkeeper/$user") { + $hint = $user; + $context = 'legacy'; + $authmode = 'legacy'; +} +elsif (OVH::Bastion::is_valid_group_and_existing(group => $user, groupType => "key")) { + $hint = $user; + $context = 'group'; + $authmode = 'group/guessed'; +} +else { + $hint = $account; + $context = 'self'; + $authmode = 'self/default'; +} +push @headers, ["X-Bastion-Auth-Mode" => $authmode]; +push @{$log_params{'custom'}}, ['auth_mode' => $authmode]; + +# check if account or group has the right to access $user@$remotemachine, time: 50ms (to re-compute) +$fnret = OVH::Bastion::is_access_granted(account => $account, user => $user, ipfrom => $ENV{'REMOTE_ADDR'}, ip => $remotemachine, port => $remoteport, listOnly => 1, sudo => 1); +if (not $fnret) { + log_and_exit(403, "Access Denied (access denied to remote)", "This account doesn't have access to this user\@host tuple ($fnret)\n", {comment => "access_denied"}); +} +else { + # check that the way we were asked to use (personal access, group access) is actually allowed for this account + my $isOk = 0; + if ($context ne 'legacy') { + foreach my $access (@{$fnret->value}) { + if ($access->{'type'} =~ /^group/ && $context eq 'group') { + $isOk = 1 if $access->{'group'} eq $hint; + } + elsif ($access->{'type'} =~ /^personal/ && $context eq 'self') { + $isOk = 1; + } + } + } + else { + # for legacy, we don't check: we didn't know how to differentiate back then + $isOk = 1; + } + + if (!$isOk) { + log_and_exit( + 403, + "Access Denied (access denied to remote this way)", + "This account doesn't have access to $user\@$remotemachine:$remoteport using this auth mode ($authmode)\n", + {comment => "access_denied_this_way"} + ); + } +} +$log_params{'allowed'} = 1; + +$fnret = OVH::Bastion::get_passfile(hint => $hint, context => $context); +if (!$fnret) { + log_and_exit(412, "Precondition Failed (egress password missing)", $fnret->msg, {comment => "no_password_found"}); +} +my $device_password; +if (open(my $pwdfile, '<', $fnret->value)) { + $device_password = <$pwdfile>; + close($pwdfile); + chomp $device_password; +} +else { + log_and_exit(500, "Internal Error (egress password unreadable)", "Couldn't read the $context '$user' egress password\n", {comment => "cannot_read_password"}); +} + +# Now build the UA for the request between bastion and remote device +my $ua = LWP::UserAgent->new(); +$ua->agent("The Bastion " . $OVH::Bastion::VERSION); +$ua->timeout($timeout); +$ua->protocols_allowed(['https']); +if ($insecure) { + $ua->ssl_opts('verify_hostname' => 0); + $ua->ssl_opts('SSL_verify_mode' => IO::Socket::SSL::SSL_VERIFY_NONE); +} + +# set the remote URL +my $req = HTTP::Request->new($method => "https://" . $remotemachine . ':' . $remoteport . $path); + +# add the content we get (passthru) +$req->content($content); + +# passthru some tolerated headers from client->bastion to bastion->device req +foreach my $key (qw{ accept content-type content-length content-encoding }) { + my @values = grep { /^\Q$key\E:/i } @client_headers; + next if not @values; + $values[0] =~ s/^\Q$key\E:\s*//i; + $req->header($key, $values[0]); +} +$req->header('Accept-Encoding' => scalar HTTP::Message::decodable()); + +# set the header to auth ourselves to the remote device +$req->header('Authorization', 'Basic ' . encode_base64($user . ':' . $device_password, '')); +undef $device_password; # no longer needed + +my $start_time = [Time::HiRes::gettimeofday()]; + +# to handle timeout properly, we fork a child, he'll do the req, and we'll wait for it, +# potentially killing it if the timeout fires +my $pipe = IO::Pipe->new; +my $childpid = fork(); +if ($childpid == 0) { + + # we are the child: make the req and return the result to our parent + $pipe->writer; + my $res; + my $downgraded = 0; + + # do the req a first time with defaults + $res = $ua->request($req); + + # if we get a HTTP/1.0 500 Can't connect to A.B.C.D:443 (SSL connect attempt failed error:1425F102:SSL routines:ssl_choose_client_version:unsupported protocol) + # ... then the device might be old and support TLS v1.0 maximum only. let's retry that silently if our caller allows + if ($allow_downgrade && $res && $res->code == 500 && $res->message =~ /ssl_choose_client_version:unsupported protocol/) { + $downgraded = 1; + $ua->ssl_opts('SSL_version' => 'TLSv1'); + $res = $ua->request($req); + } + $pipe->print(freeze({res => $res, downgraded => $downgraded})); + exit; +} + +# we are the parent: wait for our child but also consume the pipe to avoid blocking our child +$pipe->reader; +my $waiting_since = time(); +my $remaining_wait = $timeout; +my $child_data; +my $timed_out = 0; + +while (1) { + $remaining_wait = $timeout - (time() - $waiting_since); + + # we've waited long enough, bail out + if ($remaining_wait <= 0) { + $timed_out = 1; + last; + } + + my $select = IO::Select->new($pipe->fileno); + my @ready = $select->can_read($remaining_wait); + + # we either have something to read or timed out + if (@ready) { + + # we have something to read + my $newdata; + my $nbread = $pipe->read($newdata, 4096); + if (defined $nbread && $nbread > 0) { + $child_data .= $newdata; + } + else { + # 0 means EOF, undef means error + last; + } + } + else { + # we timed out, bail out + $timed_out = 1; + last; + } +} + +my $res; +my $downgraded; +if (!$timed_out) { + + # get the result of the request of our child + my $childresult = thaw($child_data); + if (ref $childresult eq 'HASH') { + $res = $childresult->{'res'}; + $downgraded = $childresult->{'downgraded'}; + } + +} +else { + # got a timeout, kill our child + kill(9, $childpid); +} + +my $delay = Time::HiRes::tv_interval($start_time, [Time::HiRes::gettimeofday()]); + +# log what we got +my $basedir = "/home/$account/ttyrec"; +-d $basedir || mkdir $basedir; + +my $finaldir = "$basedir/$remotemachine"; +-d $finaldir || mkdir $finaldir; + +my @now = Time::HiRes::gettimeofday(); +my @t = localtime($now[0]); + +my $headers_as_string = $res ? join("", $res->{'_headers'}->as_string("\n")) : ''; +my $logfile = sprintf("%s/%s.txt", $finaldir, POSIX::strftime("%F", @t)); +my $logline = sprintf( +"--- BASTION_REQUEST UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n--- DEVICE_ANSWER UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n--- END UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n\n", + $uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), $req->as_string(), + $uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), + ($res ? sprintf("%s %s\n%s\n%s", $res->protocol, $res->status_line, $headers_as_string, $res->decoded_content) : '(DEVICE TIMEOUT)'), + $uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), +); +$logline =~ s/^(Authorization:).+/$1 (removed)/mgi; + +if (open(my $log, '>>', $logfile)) { + flock($log, LOCK_EX); + print $log $logline; + flock($log, LOCK_UN); + close($log); +} +else { + warn("Couldn't open $logfile for log write"); +} + +my @passthru_headers = qw{ content-type client-ssl-cert-subject client-ssl-cipher client-ssl-warning }; +if ($res) { + foreach my $key ($res->headers->header_field_names) { + next unless (grep { lc($key) eq $_ } @passthru_headers); + push @headers, [$key => $res->header($key)]; + } +} +push @headers, ["X-Bastion-Local-Status" => ($res ? "200 OK" : "504 Device Timeout")]; +push @headers, ["X-Bastion-Remote-Status" => $res->code] if $res; +push @headers, ["X-Bastion-Remote-Server" => $res->header('server')] if ($res && $res->header('server')); +push @headers, ["X-Bastion-Egress-Timing" => sprintf("%d", $delay * 1_000_000)]; +push @headers, ["X-Bastion-Downgraded" => 1] if $downgraded; + +# custom data will only be logged to logfile and syslog, not sql (it's not in the generic schema) +if ($res) { + push @{$log_params{'custom'}}, ['code' => $res->code], ['msg' => $res->message],; +} +OVH::Bastion::log_access_insert(%log_params); + +HEXIT('OK', value => {code => $res->code + 0, msg => $res->message, body => $res->decoded_content, headers => \@headers, allowed => 1}) if $res; +HEXIT('OK', value => {code => 504, msg => "Device Timeout", body => "Device Timeout\n", headers => \@headers, allowed => 1}); diff --git a/bin/shell/autologin b/bin/shell/autologin new file mode 100755 index 0000000..60e961a --- /dev/null +++ b/bin/shell/autologin @@ -0,0 +1,111 @@ +#!/usr/bin/expect -f +# vim: set filetype=expect ts=4 sw=4 sts=4 et: +#strace 2 +set ::env(TERM) "" + +# we need 6 arguments +if { [llength $argv] < 6 } { + puts "BASTION SAYS: autologin usage error, expected 5 args: [passthrough arguments to ssh or telnet]" + exit 1 +} + +# name our arguments +set arg_prog [lindex $argv 0] +set arg_login [lindex $argv 1] +set arg_ip [lindex $argv 2] +set arg_port [lindex $argv 3] +set arg_file [lindex $argv 4] +set arg_timeout [lindex $argv 5] +set arg_remaining [lrange $argv 6 end] + +# start the program +if { $arg_prog == "ssh" } { + lappend spawn_args -l $arg_login -p $arg_port $arg_ip +} elseif { $arg_prog == "telnet" } { + lappend spawn_args $arg_ip $arg_port +} else { + puts "BASTION SAYS: autologin usage error, program must be either 'ssh' or 'telnet'" + exit 1 +} + +if { [llength $arg_remaining] > 0 } { + set spawn_args [concat $spawn_args $arg_remaining] +} + +# set the interactive timeout for expect{} blocks +set timeout $arg_timeout + +# if success, doesn't return (calls interact then exit 0) +# if auth failed, return 100 (caller might retry with another password) +# if other non-critical error, return 101 +# if critical error, exits +proc attempt_to_login args { + set tryid [lindex $args 0] + set prog [lindex $args 1] + set login [lindex $args 2] + set file [lindex $args 3] + set spawn_args [lindex $args 4] + + if { [file exists $file] == 0 } { + if { $tryid == 0 } { puts "BASTION SAYS: file $file does not exist" } + return 101 + } + if { [file readable $file] == 0 } { + if { $tryid == 0 } { puts "BASTION SAYS: file $file is not readable with our current rights" } + return 101 + } + + if { $tryid > 0 } { + puts "BASTION SAYS: trying with fallback password..." + } + + # reading password (256 chars max) + set pass_fh [open $file r] + set pass [read $pass_fh 256] + close $pass_fh + + spawn -noecho $prog {*}$spawn_args + + if { $prog == "telnet" } { + # send login (only for telnet) + expect { + -re "login:|Username:" { send -- "$login\n" } + eof { puts "BASTION SAYS: connection failed"; exit 2 } + timeout { puts "BASTION SAYS: timed out while waiting for login prompt"; exit 2 } + } + } + + # send password + expect { + -re "Password:|password:" { send -- "$pass" } + eof { puts "BASTION SAYS: connection aborted"; exit 3 } + timeout { puts "BASTION SAYS: timed out while waiting for password prompt"; exit 3 } + } + + # do we have a login success with interactive prompt? + expect { + "#" { interact; exit 0 } + ">" { interact; exit 0 } + "(enable)" { interact; exit 0 } + -re "Password:|password:|Authentication failed|Permission denied" { + if { $tryid == 0 } { puts "BASTION SAYS: authentication failed!" } + close + wait + return 100 + } + eof { puts "BASTION SAYS: connection aborted"; exit 4 } + timeout { puts "BASTION SAYS: timed out while waiting for interactive prompt on success login"; exit 4 } + } + # unreachable: + exit 5 +} + +# try to login with the main password file +set tryid 0 +set last_attempt [attempt_to_login $tryid $arg_prog $arg_login $arg_file $spawn_args] +while { $last_attempt == 100 && $tryid < 10 } { + # auth failed, might want to try with the fallback + incr tryid + set last_attempt [attempt_to_login $tryid $arg_prog $arg_login "$arg_file.$tryid" $spawn_args] +} +exit $last_attempt diff --git a/bin/shell/bastion-sync-helper.sh b/bin/shell/bastion-sync-helper.sh new file mode 100755 index 0000000..82573ce --- /dev/null +++ b/bin/shell/bastion-sync-helper.sh @@ -0,0 +1,28 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +if [ "$USER" != "bastionsync" ]; then + echo "Unexpected user, aborting" >&2 + exit 2 +fi +if [ -z "$SSH_CONNECTION" ]; then + echo "Bad environment, aborting" >&2 + exit 3 +fi +if [ "$1" != "-c" ]; then + echo "Interactive session denied, aborting" >&2 + exit 4 +fi +shift +# shellcheck disable=SC2068 +set -- $@ +if [ "$1 $2" != "rsync --server" ]; then + echo "Only rsync is allowed, aborting" >&2 + exit 5 +fi +shift +shift +if ! cd /; then + echo "Failed to chdir /, aborting" >&2 + exit 6 +fi +exec /usr/bin/sudo -- /usr/bin/rsync --server "$@" diff --git a/bin/shell/connect.pl b/bin/shell/connect.pl new file mode 100755 index 0000000..49011cf --- /dev/null +++ b/bin/shell/connect.pl @@ -0,0 +1,218 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +# this line absolutely needs to be sync with the exec() of osh.pl +# that is launching us. we don't use GetOpts or such, as this is not +# user-modifiable anyway. We're mainly passing parameters we will need +# in this short script. some of them can sometimes be undef. this is normal. +my ($ip, $sshClientHasOptionE, $userPasswordClue, $saveFile, $insert_id, $db_name, $uniq_id, @command) = @ARGV; + +# on signal (HUP happens a lot), still try to log in db +sub exit_sig { + my ($sig) = @_; + + if (defined $insert_id and defined $db_name) { + + # at that point, we might not have required the proper libs yet, do it + require File::Basename; + require '' ## no critic (BarewordIncludes) ## I trust __FILE__, no worries + . File::Basename::dirname(__FILE__) . '/../../lib/perl/OVH/Bastion.pm'; + + # and log + OVH::Bastion::log_access_update( + insert_id => $insert_id, + db_name => $db_name, + uniq_id => $uniq_id, + returnvalue => -9999, + comment => 'signal_' . $sig + ); + } + + # signal my current process group + kill $sig, 0; + + exit(117); # EXIT_GOT_SIGNAL +} +$SIG{'INT'} = \&exit_sig; +$SIG{'HUP'} = \&exit_sig; +$SIG{'TERM'} = \&exit_sig; +$SIG{'SEGV'} = \&exit_sig; + +# beautify for ps +local $0 = '' . __FILE__ . ' ' . join(' ', @command); + +# set signal for when my parent dies (Linux only) +eval { + require Linux::Prctl; + + # 1 is SIGHUP + Linux::Prctl::set_pdeathsig(1); +}; + +# As we're going to system() something passed to us via @ARGV, +# we want to be sure we're being called by something we know. +# Yes. I'm fucking paranoid. +if (open(my $fh, '<', "/proc/" . getppid() . '/cmdline')) { + my $cmdline = do { local $/ = undef; <$fh> }; + close($fh); + my @pargv = split(/\x00/, $cmdline); + + # now check our parent infos. + # regular case: ssh + if (@pargv == 1 and $pargv[0] =~ /^sshd: /) { + ; # ok, our parent is sshd, legitimate use + } + + # pingssh case + elsif (@pargv == 4 and $pargv[0] =~ m{/perl$} and $pargv[1] =~ m{/osh\.pl} and $pargv[2] eq '-c') { + ; # ok pingssh case + } + + # admin debug case: local su + elsif (@pargv == 5 and $pargv[0] eq 'su' and $pargv[1] eq '-l' and $pargv[3] eq '-c') { + print STDERR "\n\nHmm, hijack of " . $pargv[2] . " by root detected... debug I guess... okay, but it's really because it's you.\n\n"; + } + + # mosh + elsif ($pargv[0] eq 'mosh-server') { + ; # we're being called by mosh-server, alrighty + } + + # clush plugin + elsif ($pargv[1] =~ m{^/opt/bastion/bin/plugin/(open|restricted)/clush$}) { + ; # we're being called by the clush plugin, ok + } + + # interactive mode: our parent is osh.pl + elsif ($pargv[0] eq 'perl' and $pargv[1] eq '/opt/bastion/bin/shell/osh.pl') { + ; # we're being called by the interactive mode of osh.pl, ok + } + + # else: it sucks. + else { + #foreach (@pargv) { print "<".$_.">\n" }; + die("SECURITY VIOLATION, ABORTING."); + } +} +else { + ; # grsec can deny us this. if that's the case, nevermind ... bypass this check +} + +# in any case, force this +$command[0] = '/usr/bin/ttyrec'; + +# then finally launch the command ! +my $sysret = system(@command); + +# ... days or months may have passed once we arrive here, which is +# why we only used common::sense above (which is known to be light). +# using other packages would just waste memory for months as we would +# only really use them AFTER the command above has exited. + +# so. now, we can require those files we need, rejoice, we have +# saved a lot of RAM in the meantime ! + +# special case for Time::HiRes, use a `use' instead of a `require' +# in an attempt to fix a strange 'Undefined subroutine &Time::HiRes::gettimeofday' +# that happens one every 10K connections or so +use Time::HiRes qw{ gettimeofday }; +my ($timestamp, $timestampusec) = gettimeofday(); + +require File::Basename; +require '' ## no critic (BarewordIncludes) ## I trust __FILE__, no worries + . File::Basename::dirname(__FILE__) . '/../../lib/perl/OVH/Bastion.pm'; + +# ssh -E also silences normal errors on console, print them eventually +if ($sshClientHasOptionE) { + if (open(my $sshdebug, '<', $saveFile . '.sshdebug')) { + while (<$sshdebug>) { + print + unless /^debug|^key_load_public:|OpenSSL|^Authenticated to|^Transferred:|^Bytes per second:|^\s*$|client-session/; + } + close($sshdebug); + } +} + +# now guessify if the ssh worked or not +my @comments; +my $header; +if (open(my $fh_ttyrec, '<', $saveFile)) { + read $fh_ttyrec, $header, 1000; # 1K if there's the host key changed warning + close($fh_ttyrec); +} +elsif (-r "$saveFile.zst") { + my $fnret = OVH::Bastion::execute(cmd => ['zstd', '-d', '-c', "$saveFile.zst"], max_stdout_bytes => 1000, must_succeed => 1); + $header = join("\n", @{$fnret->value->{'stdout'} || []}) if $fnret; +} + +if ($header) { + if ($header =~ /Permission denied \(publickey/) { + push @comments, 'permission_denied'; + OVH::Bastion::osh_crit("BASTION SAYS: The remote server ($ip) refused all the keys we tried (see the list just above), there are FOUR things to verify:"); + OVH::Bastion::osh_warn( +"1) Check the remote account's authorized_keys on $ip, did you add the proper key there? (personal key or group key)\n2) Did you tell the bastion you added a key to the remote server, so it knows it has to use it? See the actually used keys just above. If you didn't, do it with selfAddPersonalAccess or groupAddServer.\n3) Check the from=\"\" part of the remote account's authorized_keys' keyline. Are all the bastion IPs present? Master and slave(s)? See groupInfo or selfListEgressKeys to get the proper keyline to copy/paste.\n4) Did you check the 3 above points carefully? Really? Because if you did, you wouldn't be reading this 4th bullet point, as your problem would already be fixed ;)" + ); + } + if ($header =~ /Permission denied \(keyboard-interactive/) { + push @comments, 'permission_denied'; + my $keyboardInteractiveAllowed = OVH::Bastion::config('keyboardInteractiveAllowed')->value; + OVH::Bastion::osh_crit("BASTION SAYS: The remote server ($ip) wanted to use keyboard-interactive authentication, but it's not enabled on this bastion!"); + } + if ($header =~ /Too many authentication failures/) { + push @comments, 'too_many_auth_fail'; + OVH::Bastion::osh_crit("BASTION SAYS: The remote server ($ip) disconnected us before we got a chance to try all the keys we wanted to (see the list just above)."); + OVH::Bastion::osh_warn( + "This usually happens if there are too many keys to try, for example if you have numerous personal keys of if $ip is in many groups you have access to."); + OVH::Bastion::osh_warn("Either reduce the number of keys to try, or modify $ip\'s sshd \"MaxAuthTries\" configuration option."); + } + push @comments, 'hostkey_changed' if $header =~ /IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY/; + push @comments, 'hostkey_saved' if $header =~ /Warning: Permanently added /; + if ($header =~ /ssh: connect to host \S+ port \d+: Connection timed out/) { + push @comments, 'connection_timeout'; + } + elsif ($header =~ /ssh: connect to host \S+ port \d+: Connection refused/) { + push @comments, 'connection_refused'; + } + elsif ($header =~ /ssh: connect to host \S+ port \d+: /) { + push @comments, 'connection_error'; + } + elsif ($header =~ /disabled to avoid man-in-the-middle/) { + push @comments, 'passauth_disabled'; + + # be nice and explain to the user cf ticket BASTION-10 + if ($userPasswordClue) { + OVH::Bastion::osh_crit("BASTION SAYS: Password auth is blocked because of the hostkey mismatch on $ip."); + OVH::Bastion::osh_crit("If you are aware of this change, remove the hostkey cache with --osh selfForgetHostKey --host $ip --port PORT"); + } + } + if ($header =~ /remove with: ssh-keygen -f "\S+" -R (\S+)/) { + + # be nice and explain how to remove this warning + OVH::Bastion::osh_crit("BASTION SAYS: If you know why the hostkey changed and you know this is normal, you can remove this warning by using the following command:"); + my $bastionName = OVH::Bastion::config('bastionName')->value; + OVH::Bastion::osh_crit("$bastionName --osh selfForgetHostKey --host $1"); + } +} +else { + push @comments, 'ttyrec_error'; +} + +# update our sql line if we successfully inserted it back in osh.pl +OVH::Bastion::log_access_update( + insert_id => $insert_id, + db_name => $db_name, + uniq_id => $uniq_id, + returnvalue => $sysret, + comment => @comments ? join(' ', @comments) : undef, + timestampend => $timestamp, + timestampendusec => $timestampusec, + ttyrecsize => (stat($saveFile))[7], # FIXME we miss ttyrec rotations, need to fix a bug in ovh-ttyrec before ... +); + +if ($sysret == -1) { + OVH::Bastion::osh_crit("Couldn't start " . join('|', @command) . ($! ? " ($!)" : ", is it installed?")); + exit($sysret); +} + +exit($sysret >> 8); diff --git a/bin/shell/osh.pl b/bin/shell/osh.pl new file mode 100755 index 0000000..0acc6c6 --- /dev/null +++ b/bin/shell/osh.pl @@ -0,0 +1,1471 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use 5.010; +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; + +use Getopt::Long qw(GetOptionsFromString :config pass_through no_ignore_case); +use Sys::Hostname; +use POSIX qw(strftime); +use Term::ANSIColor; + +$ENV{'LANG'} = 'C'; +$| = 1; +my $fnret; + +# +# Signals +# + +$SIG{'INT'} = \&exit_sig; +$SIG{'TERM'} = \&exit_sig; +$SIG{'SEGV'} = \&exit_sig; +$SIG{'HUP'} = \&exit_sig; + +# +# Do just what is needed before the first call to main_exit in the code flow +# + +# tell Getopt::Long to not try to be smart, it messes up with plugins +Getopt::Long::Configure("no_auto_abbrev"); + +# safe umask +umask(0027); + +# sanitize user for taint mode +my $self = OVH::Bastion::get_user_from_env()->value; +my $home = OVH::Bastion::get_home_from_env()->value; +my ($sysself, $realm, $remoteself); # to handle realm cases, will be filled later, look for # REALM below + +# both needs to be there because in case of SIG, we need them in the handler +my $log_db_name = undef; +my $log_insert_id = undef; + +# set a uniqid that will be used in syslog, both sqls, and ttyrec name, so we can search for the same event +my $log_uniq_id = OVH::Bastion::generate_uniq_id()->value; +$ENV{'UNIQID'} = $log_uniq_id; # some modules need it, also used in warn/die handler + +# fetch basic connection info +my ($ipfrom, $portfrom, $bastionip, $bastionport) = split(/\s/, $ENV{'SSH_CONNECTION'}); +my $hostfrom = OVH::Bastion::ip2host($ipfrom)->value || $ipfrom; +my $bastionhost = OVH::Bastion::ip2host($bastionip)->value || $bastionip; + +# sub used to exit from this shell, also handles logs for early exits +sub main_exit { + my ($retcode, $comment, $msg) = @_; + + # if, this is an early exit, we didn't log anything yet in the sql, do it now + OVH::Bastion::log_access_insert( + account => $self, + cmdtype => 'abort', + allowed => undef, + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => undef, + hostto => undef, + portto => undef, + user => undef, + plugin => undef, + params => join('^', @ARGV), + comment => $comment, + uniqid => $log_uniq_id + ) if (not defined $log_db_name or not defined $log_insert_id); + + my $R = R($retcode eq OVH::Bastion::EXIT_OK ? 'OK' : 'KO_' . uc($comment), msg => $msg); + + OVH::Bastion::osh_crit($R->msg) if not $R; + OVH::Bastion::json_output($R) if $ENV{'PLUGIN_JSON'}; + + exit $retcode; +} + +# Safeness check + +if (not defined $self) { + + # wow, that's weird, stop here + $self = ''; + main_exit(OVH::Bastion::EXIT_EXEC_FAILED, "security_violation", "USER is not defined"); +} + +# +# Now, load config +# + +$fnret = OVH::Bastion::load_configuration(); +$fnret or main_exit(OVH::Bastion::EXIT_CONFIGURATION_FAILURE, "configuration_failure", $fnret->msg); +my $config = $fnret->value; + +my $bastionName = $config->{'bastionName'}; +my $osh_debug = $config->{'debug'}; + +# REALM case: somebody from another realm (named xyz) connects with the realm_xyz account here, +# and the real remote account name (which doesn't have an account here because it's from another realm) +# is passed through LC_BASTION +if ($self =~ /^realm_([a-zA-Z0-9_.-]+)/) { + $self = sprintf("%s/%s", $1, $ENV{'LC_BASTION'}); + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $self, realmOnly => 1); + $fnret or main_exit(OVH::Bastion::EXIT_ACCOUNT_INVALID, "account_invalid", "The realm-scoped account '$self' is invalid (" . $fnret->msg . ")"); +} +else { + # non-realm case + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $self); + $fnret or main_exit(OVH::Bastion::EXIT_ACCOUNT_INVALID, "account_invalid", "The account is invalid (" . $fnret->msg . ")"); +} +{ + my %values = %{$fnret->value}; + ($sysself, $self, $realm, $remoteself) = @values{qw{ sysaccount account realm remoteaccount }}; +} + +# +# First Check : is USER valid ? +# +my $activenessDenyOnFailure = OVH::Bastion::config("accountExternalValidationDenyOnFailure")->value; +my $msg_to_print_delayed; # if set, will be osh_warn()'ed if we're connecting through ssh (i.e. not scp, it breaks it) +$fnret = OVH::Bastion::is_account_active(account => $self); +if ($fnret) { + ; # OK +} +elsif ($fnret->is_ko || ($activenessDenyOnFailure && $fnret->is_err)) { + main_exit OVH::Bastion::EXIT_ACCOUNT_INACTIVE, "account_inactive", "Your account is inactive, $self, sorry"; +} +else { + $msg_to_print_delayed = $fnret->msg; +} + +# +# Now : are we in maintenance mode ? +# +if (-e '/home/allowkeeper/maintenance') { + osh_crit "This bastion is currently in maintenance mode, new connections are not allowed."; + my $maintenance_message = '(unknown)'; + if (open(my $fh, '<', '/home/allowkeeper/maintenance')) { + local $/ = undef; + $maintenance_message = <$fh>; + close($fh); + } + osh_warn "The maintenance reason is as follows: $maintenance_message"; + if (OVH::Bastion::is_admin()) { + osh_warn "You are a bastion admin, allowing anyway, but it's really because it's you."; + } + else { + main_exit(OVH::Bastion::EXIT_MAINTENANCE_MODE, "maintenance_mode", $maintenance_message); + } +} + +# +# Does the user have a TTL, and if yes, has it expired? +# + +$fnret = OVH::Bastion::account_config(account => $self, key => "account_ttl"); +if ($fnret) { + if ($fnret->value !~ /^\d+$/) { + main_exit(OVH::Bastion::EXIT_TTL_EXPIRED, "ttl_expired", "Your TTL has an invalid value, access denied. Check with an administrator."); + } + my $ttl = $fnret->value; + + $fnret = OVH::Bastion::account_config(account => $self, key => "creation_timestamp"); + if ($fnret->value !~ /^\d+$/) { + main_exit(OVH::Bastion::EXIT_TTL_EXPIRED, + "ttl_expired", "Your account creation date has an invalid value, and you have a TTL set, access denied. Check with an administrator."); + } + my $created = $fnret->value; + + if ($created + $ttl < time()) { + main_exit(OVH::Bastion::EXIT_TTL_EXPIRED, "ttl_expired", "Sorry $self, your account has expired."); + } +} + +# +# Second check : has account logged-in recently enough to be allowed ? +# +$fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $sysself, remoteaccount => $remoteself); +if ($fnret->is_err) { + + # internal error, warn and pass + osh_warn($fnret); +} +elsif ($fnret->is_ko) { + + # expired + main_exit OVH::Bastion::EXIT_ACCOUNT_EXPIRED, 'account_expired', $fnret->msg; +} +my $lastlog_filepath = $fnret->value->{'filepath'}; + +my $lastlogmsg = sprintf("Welcome to $bastionName, $self, this is your first connection"); +if ($fnret && $fnret->value && $fnret->value->{'seconds'}) { + my $lastloginfo = $fnret->value->{'info'} ? " from " . $fnret->value->{'info'} : ""; + $fnret = OVH::Bastion::duration2human(seconds => $fnret->value->{'seconds'}, tense => "past"); + $lastlogmsg = sprintf("Welcome to $bastionName, $self, your last login was %s ago (%s)%s", $fnret->value->{'duration'}, $fnret->value->{'date'}, $lastloginfo); +} + +# ok not expired, so we update lastlog +if ($lastlog_filepath && open(my $lastlogfh, '>', $lastlog_filepath)) { + print $lastlogfh sprintf("%s(%s)", $ipfrom, $hostfrom); + close($lastlogfh); +} +else { + osh_warn "Couldn't update your lastlog ($lastlog_filepath: $!), contact a bastion admin"; +} + +# +# Fetch command options +# +my @saved_argv = @ARGV; + +# these options are the ones on shell definition of user calling osh.pl, +# the user-passed commands are stringified after "-c" (as in sh -c) +# it's possible to define the shell as osh.pl --debug, to force debug +my $realOptions; +my $opt_debug; +my $result = GetOptions( + "c=s" => \$realOptions, # user command under -c '...' + "debug" => \$opt_debug, +); + +if (not $result or not $realOptions) { + help(); + main_exit OVH::Bastion::EXIT_UNKNOWN_COMMAND, "unknown_command", "Bad or empty command"; +} + +$osh_debug = 1 if $opt_debug; # osh_debug was already 1 if specified in config file + +# per-user debug ? +$fnret = OVH::Bastion::account_config(account => $self, key => "debug"); +if ($fnret and $fnret->value() =~ /yes/) { + $osh_debug = 1; +} + +$ENV{'OSH_DEBUG'} = 1 if $osh_debug; + +osh_debug("self=$self home=$home realm=$realm remoteself=$remoteself sysself=$sysself"); +osh_debug("user-passed options : $realOptions"); + +# +# Command params +# + +my $port = 22; # can be override by special port +my @toExecute; + +# special case: mosh, in that case we have something like this in $realOptions +# mosh-server 'new' '-s' '-c' '256' '-l' 'LANG=en_US.UTF-8' '-l' 'LANGUAGE=en_US' '--' '--osh' 'info' +if ($realOptions =~ /^mosh-server (.+?) '--' (.*)/) { + osh_debug("MOSH DETECTED (with params)"); + + # remove mosh stuff and save it for later + my $mosh = $1; + $realOptions = $2; + $ENV{'MOSH_SERVER_NETWORK_TMOUT'} = OVH::Bastion::config('moshTimeoutNetwork')->value(); + $ENV{'MOSH_SERVER_SIGNAL_TMOUT'} = OVH::Bastion::config('moshTimeoutSignal')->value(); + + # get @toExecute params from the stuff we got from mosh-client (stored in $mosh) ? + # or maybe not... I don't trust users, and we need to override some things anyway (such as ports) + @toExecute = qw{ mosh-server new -s -l LANG=en_US.UTF-8 -l LANGUAGE=en_US }; + + # add what has been specified in the config + my @moshCommandLine = split(/\s+/, OVH::Bastion::config('moshCommandLine')->value()); + push @toExecute, @moshCommandLine if @moshCommandLine; + + # okay, just extract the -c 256 / -c 8 from the command because it depends on the user terminal spec + my $colors = 8; # by default + if ($mosh =~ m/'-c' '(\d+)'/) { + $colors = $1; + } + push @toExecute, ('-c', $colors, '--'); + + # mosh has the bad habit of surrounding every param with simple quotes ('), and escaping ' by '\'', + # because it thinks we are a POSIX shell, but we're not. So get around that + osh_debug("mosh params: $2"); + + # now unescape mosh params... yay regexes! + $realOptions =~ s/(?"); + if (not $config->{'moshAllowed'}) { + main_exit OVH::Bastion::EXIT_MOSH_DISABLED, "mosh_disabled", "Mosh support has been disabled on this bastion"; + } +} +elsif ($realOptions =~ /^mosh-server /) { + osh_debug("MOSH DETECTED (without any param)"); + + # we won't really use mosh, as we'll exit later with the bastion help anyway + $realOptions = ''; +} + +# If there is a '--' in command line, protect all the end of the command line +# in order to let it in one block after command line parsing + +my $beforeOptions; +my $afterOptions; + +if ($realOptions =~ /^(.*?) -- (.*)$/) { + $beforeOptions = $1; + $afterOptions = $2; + osh_debug("before <$beforeOptions> after <$afterOptions>"); + if (($config->{'remoteCommandEscapeByDefault'} and not $beforeOptions =~ /(^| )--never-escape( |$)/) + or $beforeOptions =~ /(^| )--always-escape( |$)/) + { + # ugly / legacy mode: escape ' with \' + $afterOptions =~ s/'/\\'/g; + osh_debug("afterOptions after legacy voodoo is <$afterOptions>"); + } + else { + osh_debug("afterOptions without legacy voodoo is <$afterOptions>"); + } +} +else { + # we have no -- delimiter, either there was no remote command (that's fine), + # or it's indistiguishable from the bastion options, in that case GetOptionsFromString + # will leave what it doesn't recognize, will also fuck up "" and '', but users are warned + # to always use -- anyway, and we'll use that as a remote command + $beforeOptions = $realOptions; + $afterOptions = undef; # will contain the GetOptionsFromString leftovers +} + +my $remainingOptions; +($result, $remainingOptions) = GetOptionsFromString( + $beforeOptions, + "port|p=i" => \my $optPort, + "verbose+" => \my $verbose, + "tty|t" => \my $tty, + "no-tty|T" => \my $notty, + "user|u=s" => \my $user, + "osh=s" => \my $osh_command, + "telnet|e" => \my $telnet, + "password=s" => \my $passwordFile, + "P" => \my $selfPassword, + "host|h=s" => \my $host, + "help" => \my $help, + "long-help" => \my $longHelp, + "quiet|q" => \my $quiet, + "timeout=i" => \my $timeout, + "bind=s" => \my $bind, + "debug" => \my $debug, + "json" => \my $json, + "json-greppable" => \my $json_greppable, + "json-pretty" => \my $json_pretty, + "always-escape" => \my $_dummy1, # not used as corresponding option has already been ninja-used above + "never-escape" => \my $_dummy2, # not used as corresponding option has already been ninja-used above + "interactive|i" => \my $interactive, + "netconf" => \my $netconf, + "wait" => \my $wait, + "ssh-as=s" => \my $sshAs, + "use-key=s" => \my $useKey, + "kbd-interactive" => \my $userKbdInteractive, +); + +if (!$quiet && $realm && !$ENV{'OSH_NO_INTERACTIVE'}) { + my $welcome = + "You are now connected to " . colored($bastionName, "yellow") . ". Welcome, " . colored($remoteself, "yellow") . ", citizen of the " . colored($realm, "yellow") . " realm!"; + print colored("-" x (length($welcome) - 3 * 9) . "\n", "bold yellow"); + print $welcome. "\n"; + print colored("-" x (length($welcome) - 3 * 9) . "\n", "bold yellow"); + print "\n"; +} +osh_debug("remainingOptions <" . join('/', @$remainingOptions) . ">"); + +if (defined $afterOptions and @$remainingOptions > 1) { + + # user specified -- but there are more than 1 unrecognized param (the 1 should be the user@host) + # so we warn that we didn't understood + osh_warn "WARN : I couldn't parse some of your options before the '--' delimiter, things are probably about to go very wrong\n"; +} +if (not defined $afterOptions and @$remainingOptions > 1 and not $osh_command) { + osh_warn "WARN : You did not use the '--' delimiter to pass your remote command, maybe something crazy will happen !\n"; +} + +if ($afterOptions) { + push @$remainingOptions, split(/ /, $afterOptions); + osh_debug("remainingOptionsAfterAdd <" . join('/', @$remainingOptions) . ">"); +} + +if ($json_pretty) { + $ENV{'PLUGIN_JSON'} = 'PRETTY'; +} +elsif ($json_greppable) { + $ENV{'PLUGIN_JSON'} = 'GREP'; +} +elsif ($json) { + $ENV{'PLUGIN_JSON'} = 'DEFAULT'; +} + +if ($quiet || $json || $json_pretty || $json_greppable) { + + # remove colors + $ENV{'ANSI_COLORS_DISABLED'} = 1; # cf Term::ANSIColor; +} + +if (!$result) { + help(); + main_exit OVH::Bastion::EXIT_GETOPTS_FAILED, 'getopts_failed', "Error parsing command line options"; +} + +if ($help and not $osh_command) { + help(); + main_exit OVH::Bastion::EXIT_OK, 'help', ''; +} + +if ($longHelp) { + long_help(); + main_exit OVH::Bastion::EXIT_OK, 'long_help', ''; +} + +if ($bind) { + $fnret = OVH::Bastion::get_bastion_ips(); + if ($fnret) { + if (not grep { $bind eq $_ } @{$fnret->value}) { + main_exit OVH::Bastion::EXIT_CONFLICTING_OPTIONS, "invalid_bind", "Invalid binding IP specified ($bind)"; + } + } +} + +if ($interactive and not $ENV{'OSH_NO_INTERACTIVE'}) { + if (not $config->{'interactiveModeAllowed'}) { + main_exit OVH::Bastion::EXIT_INTERACTIVE_DISABLED, "interactive_disabled", "Interactive mode has been disabled on this bastion"; + } + if ($osh_command) { + main_exit OVH::Bastion::EXIT_CONFLICTING_OPTIONS, "conflicting_options", "Incompatible options specified: --interactive and --osh"; + } + if (@toExecute) { + + # hmm, we are under mosh, mosh needs something to exec, so let's + # re-exec ourselves in interactive mode + exec(@toExecute, $0, '-c', $realOptions); + } + + my $logret = OVH::Bastion::log_access_insert( + account => $self, + cmdtype => 'interactive', + allowed => 1, + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => undef, + hostto => undef, + portto => undef, + user => undef, + plugin => undef, + params => undef, + comment => undef, + uniqid => $log_uniq_id + ); + if ($logret) { + + # needed for the log_access_update func after we're done with the command + $log_insert_id = $logret->value->{'insert_id'}; + $log_db_name = $logret->value->{'db_name'}; + } + else { + osh_warn($logret->msg); + } + + OVH::Bastion::interactive(realOptions => $realOptions, timeoutHandler => \&exit_sig, self => $self); + + # this functions may never return, especially in case of idle timeout exit + + if (defined $log_insert_id and defined $log_db_name) { + $logret = OVH::Bastion::log_access_update( + insert_id => $log_insert_id, + db_name => $log_db_name, + uniq_id => $log_uniq_id, + returnvalue => undef, + plugin_stdout => undef, + plugin_stderr => undef + ); + $logret or osh_warn($logret->msg); + } + main_exit OVH::Bastion::EXIT_OK, 'interactive', ''; +} + +# If it's an osh command +# we'll pass the remaining options to the plugin +my $remainingOptionsCounter = scalar(@$remainingOptions); +my $command; +if ($osh_command) { + ($help) and $ENV{'PLUGIN_HELP'} = 1; + ($quiet) and $ENV{'PLUGIN_QUIET'} = 1; + ($osh_debug) and $ENV{'PLUGIN_DEBUG'} = 1; + ($debug) and $ENV{'PLUGIN_DEBUG'} = 1; + osh_debug('Going got pass the following supplement args to plugin: ' . join('^', @$remainingOptions)); +} +else { + # it's ssh or telnet => it may remain at least 'host' or 'user@host' + osh_debug("Remaining options " . join('/', @$remainingOptions)); + if ($remainingOptionsCounter == 0) { + if (!$host) { + help(); + main_exit OVH::Bastion::EXIT_NO_HOST, 'no_host', "No osh command specified and no host to connect to"; + } + else { + ; # we have an host with option -h + } + } + else { + $host = shift(@{$remainingOptions}); + if ($host eq '-osh' || $host eq '--osh') { + + # special case when using -osh without argument + $osh_command = 'help'; + $host = ''; + } + else { + $remainingOptionsCounter--; + osh_debug("host = $host"); + if ($host =~ /^([\S\d\w\.\-_]+)\@([\S\d\w\.\-_]+)$/) { + $user = $1; + $host = $2; + } + osh_debug("user $user host $host"); + } + } + + if ($remainingOptionsCounter > 0) { + $command .= join(' ', @$remainingOptions); + osh_debug("Going to add extra command $command"); + } +} + +# Get real ip from host +$fnret = R('ERR_INTERNAL', silent => 1); +my $ip = undef; + +# if: avoid loading Net::IP and BigInt if there's no host specified +if ($host) { + $fnret = OVH::Bastion::get_ip(host => $host); +} +if (!$fnret) { + + # exit error when not osh ... + if (!$osh_command) { + main_exit OVH::Bastion::EXIT_HOST_NOT_FOUND, 'host_not_found', "Unable to resolve host '$host' ($fnret)"; + } + elsif ($host && $host !~ m{^[0-9.:]+/\d+$}) # in some osh plugins, ip/mask is accepted, don't yell. + { + osh_warn("I was unable to resolve host '$host'. Something shitty might happen."); + } +} +else { + $ip = $fnret->value->{'ip'}; +} + +osh_debug("will work on IP $ip"); + +# Check if we got a telnet or ssh password user +my $userPasswordClue; +my $userPasswordContext; +if (defined $user and $user =~ /^(telnet|ssh)-passw(or)?d-([^-]+)(-([^-]+))?$/) { + my $method = $1; + + # update user + $user = $3; + + if ($4) { + $userPasswordClue = $5; + } + else { + $userPasswordClue = $user; + } + + if ($method eq 'telnet') { + + # as if user specified -e aka --telnet + $telnet = 1; + } + + $userPasswordContext = 'group'; +} +elsif ($passwordFile) { + $userPasswordClue = $passwordFile; + $userPasswordContext = 'group'; +} +elsif ($selfPassword) { + $userPasswordClue = $self; + $userPasswordContext = 'self'; +} + +osh_debug("Will use password file $userPasswordClue with user $user under context $userPasswordContext") if $userPasswordClue; + +if ($optPort) { + $port = $optPort; +} +elsif ($telnet) { + $port = 23; +} +else { + $port = 22; +} + +if ($telnet && !$config->{'telnetAllowed'}) { + main_exit OVH::Bastion::EXIT_ACCESS_DENIED, 'telnet_denied', "Sorry, the telnet protocol has been disabled by policy"; +} + +if ($userKbdInteractive && !$config->{'keyboardInteractiveAllowed'}) { + main_exit OVH::Bastion::EXIT_CONFLICTING_OPTIONS, 'kbd_interactive_denied', "Sorry, the keyboard-interactive egress authentication scheme has been disabled by policy"; +} +$ENV{'OSH_KBD_INTERACTIVE'} = 1 if $userKbdInteractive; # useful for plugins that need to call ssh by themselves (for example to test a connection, i.e. groupAddServer) + +# MFA enforcing for ingress connection, either on global bastion config, or on specific account config +my $mfaPolicy = OVH::Bastion::config('accountMFAPolicy')->value; +my $isMfaPasswordConfigured = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP); +my $isMfaTOTPConfigured = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP); +my $isMfaPasswordRequired = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP); +my $hasMfaPasswordBypass = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP); +my $isMfaTOTPRequired = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP); +my $hasMfaTOTPBypass = OVH::Bastion::is_user_in_group(account => $sysself, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP); +if ($mfaPolicy ne 'disabled' && !grep { $osh_command eq $_ } qw{ selfMFASetupPassword selfMFASetupTOTP help info }) { + + if (($mfaPolicy eq 'password-required' && !$hasMfaPasswordBypass) || $isMfaPasswordRequired) { + main_exit(OVH::Bastion::EXIT_MFA_PASSWORD_SETUP_REQUIRED, + 'mfa_password_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before using this bastion, please use the `--osh selfMFASetupPassword' option to do so") + if !$isMfaPasswordConfigured; + } + + if (($mfaPolicy eq 'totp-required' && !$hasMfaTOTPBypass) || $isMfaTOTPRequired) { + main_exit(OVH::Bastion::EXIT_MFA_TOTP_SETUP_REQUIRED, + 'mfa_totp_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before using this bastion, please use the `--osh selfMFASetupTOTP' option to do so") + if !$isMfaTOTPConfigured; + } + + if ($mfaPolicy eq 'any-required' && (!$isMfaPasswordConfigured && !$hasMfaPasswordBypass) && (!$isMfaTOTPConfigured && !$hasMfaTOTPBypass)) { + main_exit(OVH::Bastion::EXIT_MFA_ANY_SETUP_REQUIRED, 'mfa_any_setup_required', +"Sorry, but you need to setup the Multi-Factor Authentication before using this bastion, please use either the `--osh selfMFASetupPassword' or the `--osh selfMFASetupTOTP' option, at your discretion, to do so" + ); + } +} + +# /MFA enforcing + +osh_debug("self : " + . (defined $self ? $self : '') . "\n" + . "user : " + . (defined $user ? $user : '') . "\n" + . "host : " + . (defined $host ? $host : '') . "\n" + . "port : " + . (defined $port ? $port : '') . "\n" + . "verbose : " + . (defined $verbose ? $verbose : '') . "\n" + . "tty : " + . (defined $tty ? $tty : '') . "\n" + . "osh : " + . (defined $osh_command ? $osh_command : '') . "\n" + . "command : " + . (defined $command ? $command : '') + . "\n"); + +my $hostto = OVH::Bastion::ip2host($host)->value || $host; + +# Special case: adminSudo for ssh connection as another user +if ($sshAs) { + $fnret = OVH::Bastion::is_admin(account => $self); + my $logret = OVH::Bastion::log_access_insert( + account => $self, + cmdtype => 'sshas', + allowed => $fnret ? 1 : 0, + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => $ip, + hostto => $hostto, + portto => $optPort, + user => $user, + plugin => undef, + params => join(' ', @$remainingOptions), + comment => undef, + uniqid => $log_uniq_id + ); + if (!$fnret) { + main_exit OVH::Bastion::EXIT_RESTRICTED_COMMAND, "sshas_denied", "Sorry, this feature is reserved to bastion administrators. Your attempt has been logged."; + } + if ($osh_command) { + main_exit OVH::Bastion::EXIT_CONFLICTING_OPTIONS, "conflicting_options", + "Can't use --ssh-as and --osh together. If you want to run a plugin as another user, use --osh adminSudo"; + } + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $sshAs); + $fnret or main_exit OVH::Bastion::EXIT_ACCESS_DENIED, 'invalid_account', "Sorry, the specified account is invalid"; + + my @cmd = qw( sudo -n -u ); + push @cmd, $sshAs; + push @cmd, qw( -- /usr/bin/env perl ); + push @cmd, $OVH::Bastion::BASEPATH . '/bin/shell/osh.pl'; + push @cmd, '-c'; + + my @forwardOptions; + push @forwardOptions, "--user", $user if $user; + push @forwardOptions, "--host", $host if $host; + push @forwardOptions, "--port", $port if $port; + push @forwardOptions, @$remainingOptions if ($remainingOptions and @$remainingOptions); + + if (not @forwardOptions) { + main_exit OVH::Bastion::EXIT_NO_HOST, 'no_host', "No osh command specified and no host to connect to"; + } + + push @cmd, join(" ", @forwardOptions); + + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'admin-ssh-as'], ['account' => $self], ['sudo-as', $sshAs], ['plugin', 'ssh'], ['params', join(" ", @forwardOptions)]] + ); + + osh_warn("ADMIN SUDO: $self, you'll now impersonate $sshAs, this has been logged."); + + exec(@cmd) or main_exit(OVH::Bastion::EXIT_EXEC_FAILED, "ssh_as_failed", "Couldn't start a session under the account $sshAs ($!)"); +} + +# +# First case. We have an OSH command +# +if ($osh_command) { + + # For backward compatibility, accept old names of plugins + my %legacy2new = qw( + accountAddFullGroupAccess groupAddMember + accountDelFullGroupAccess groupDelMember + accountAddPartialGroupAccess groupAddGuestAccess + accountDelPartialGroupAccess groupDelGuestAccess + accountListPartialGroupAccess groupListGuestAccesses + selfListKeys selfListIngressKeys + selfAddKey selfAddIngressKey + selfDelKey selfDelIngressKey + selfListBastionKeys selfListEgressKeys + selfGenerateBastionKey selfGenerateEgressKey + selfAddPrivateAccess selfAddPersonalAccess + selfDelPrivateAccess selfDelPersonalAccess + accountAddPrivateAccess accountAddPersonalAccess + accountDelPrivateAccess accountDelPersonalAccess + accountListBastionKeys accountListEgressKeys + accountListKeys accountListIngressKeys + accountResetKeys accountResetIngressKeys + helloWorld info + groupGenerateEgressPassword groupGeneratePassword + groupListEgressPasswords groupListPasswords + selfListEgressPasswords selfListPasswords + ); + $osh_command = $legacy2new{$osh_command} if $legacy2new{$osh_command}; + + # Then test for rights + $fnret = OVH::Bastion::can_account_execute_plugin(account => $self, plugin => $osh_command); + + my $logret = OVH::Bastion::log_access_insert( + account => $self, + cmdtype => 'osh', + allowed => ($fnret ? 1 : 0), + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => $ip, + hostto => $hostto, + portto => $optPort, + user => $user, + plugin => $osh_command, + params => join(' ', @$remainingOptions), + comment => 'plugin-' . ($fnret->value ? $fnret->value->{'type'} : 'UNDEF'), + uniqid => $log_uniq_id + ); + if ($logret) { + + # needed for the log_access_update func after we're done with the command + $log_insert_id = $logret->value->{'insert_id'}; + $log_db_name = $logret->value->{'db_name'}; + } + else { + warn_syslog("Failed to insert accesss log: " . $logret->msg); + if ($ip eq '127.0.0.1') { + osh_warn("Would deny access on out of space condition but you're root\@127.0.0.1, I hope you're here to fix me!"); + } + else { + main_exit OVH::Bastion::EXIT_OUT_OF_SPACE, 'out_of_space', "Bastion is out of space, admin intervention is needed! (" . $logret->msg . ")"; + } + } + + if ($fnret) { + my @cmd = ($fnret->value->{'fullpath'}, $user, $ip, $host, $optPort, @$remainingOptions); + + # is plugin explicitely disabled? + my $isDisabled = OVH::Bastion::plugin_config(plugin => $osh_command, key => "disabled"); + + # plugin is enabled by default if not explicitely disabled + if ($isDisabled and $isDisabled->value() =~ /yes/) { + main_exit OVH::Bastion::EXIT_RESTRICTED_COMMAND, "plugin_disabled", "Sorry, this plugin has been disabled by policy."; + } + if ($isDisabled->is_err && $isDisabled->err ne 'KO_NO_SUCH_FILE') { + warn_syslog("Failed to tell whether the '$osh_command' plugin is enabled or not (" . $isDisabled->msg . ")"); + main_exit OVH::Bastion::EXIT_RESTRICTED_COMMAND, "plugin_disabled", + "Sorry, a configuration error prevents us to check whether this plugin is enabled, warn your sysadmin!"; + } + + # check if we need JIT MFA to call this plugin, this can be configured per-plugin + # TODO: autodetect if the MFA check is done outside of the code by sshd+PAM, to avoid re-asking for it here + my $MFArequiredForPlugin = OVH::Bastion::plugin_config(plugin => $osh_command, key => "mfa_required")->value; + $MFArequiredForPlugin ||= 'none'; # no config means none + # some plugins need an explicit MFA check before being called (mainly plugins manipulating authentication factors) + # if the user wants to reset one of its MFA tokens, force require MFA + if ((grep { $osh_command eq $_ } qw{ selfMFAResetPassword selfMFAResetTOTP }) && ($MFArequiredForPlugin eq 'none')) { + + # enforce MFA in those cases, even if it's not configured + $MFArequiredForPlugin = 'any'; + } + + # if the user wants to setup TOTP, if it happens to be already set (or any other factor), require it too + # note: this is not needed for selfMFASetupPassword, because `passwd` does the job of asking the previous password + elsif ($osh_command eq 'selfMFASetupTOTP' && ($isMfaTOTPConfigured || $isMfaPasswordConfigured) && ($MFArequiredForPlugin eq 'none')) { + $MFArequiredForPlugin = 'any'; + } + + if (!grep { $MFArequiredForPlugin eq $_ } qw{ password totp any none }) { + main_exit(OVH::Bastion::EXIT_MFA_FAILED, 'mfa_plugin_configuration_failed', "MFA configuration is incorrect for this plugin, report to your sysadmin!"); + } + my $skipMFA = 0; + if ($MFArequiredForPlugin eq 'password' && !$isMfaPasswordConfigured) { + if ($hasMfaPasswordBypass) { + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_PASSWORD_SETUP_REQUIRED, + 'mfa_password_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before using this command,\n" . "please use the `--osh selfMFASetupPassword' option to do so"); + } + } + elsif ($MFArequiredForPlugin eq 'totp' && !$isMfaTOTPConfigured) { + if ($hasMfaTOTPBypass) { + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_TOTP_SETUP_REQUIRED, + 'mfa_totp_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before using this command,\n" . "please use the `--osh selfMFASetupTOTP' option to do so"); + } + } + elsif ($MFArequiredForPlugin eq 'any' && !$isMfaTOTPConfigured && !$isMfaPasswordConfigured) { + if ($hasMfaPasswordBypass && $hasMfaTOTPBypass) { + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_ANY_SETUP_REQUIRED, 'mfa_any_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before using this command,\n" + . "please use either the `--osh selfMFASetupPassword' or the `--osh selfMFASetupTOTP' option, at your discretion, to do so"); + } + } + + # and start the MFA phase if needed + if ($MFArequiredForPlugin ne 'none' && !$skipMFA) { + print "As this is required to run this plugin, entering MFA phase.\n"; + + # use system() instead of OVH::Bastion::execute() because we need it to grab the term + my $pamtries = 3; + while (1) { + my $pamsysret = system('pamtester', 'sshd', $sysself, 'authenticate'); + if ($pamsysret < 0) { + main_exit(OVH::Bastion::EXIT_MFA_FAILED, 'mfa_failed', "MFA is required for this plugin, but this bastion is missing the `pamtester' tool, aborting"); + } + elsif ($pamsysret != 0) { + if (--$pamtries <= 0) { + main_exit(OVH::Bastion::EXIT_MFA_FAILED, 'mfa_failed', "Sorry, but Multi-Factor Authentication failed, aborting"); + } + next; + } + + # success, if we are configured to launch a external command on pamtester success, do it. + # see the bastion.conf.dist file for usage example. + my $MFAPostCommand = OVH::Bastion::config('MFAPostCommand')->value; + if (ref $MFAPostCommand eq 'ARRAY' && @$MFAPostCommand) { + s/%ACCOUNT%/$self/g for @$MFAPostCommand; + $fnret = OVH::Bastion::execute(cmd => $MFAPostCommand, must_succeed => 1); + if (!$fnret) { + warn_syslog("MFAPostCommand returned a non-zero value: " . $fnret->msg); + } + } + last; + } + } + + OVH::Bastion::set_terminal_mode_for_plugin(plugin => $osh_command, action => 'set'); + if (OVH::Bastion::is_bsd() && $osh_command eq 'selfMFASetupPassword') { + system(@cmd); + $fnret = R('OK', value => {sysret => $?}); + } + else { + # some plugins need to be called with system() instead of ::execute + my $is_binary; + my $system; + $fnret = OVH::Bastion::plugin_config(plugin => $osh_command, key => "execution_mode"); + if ($fnret && $fnret->value) { + $system = 1 if $fnret->value eq 'system'; + $is_binary = 1 if $fnret->value eq 'binary'; + } + $ENV{'OSH_IP_FROM'} = $ipfrom; # used in some plugins for is_access_granted() + $fnret = OVH::Bastion::execute( + cmd => \@cmd, + noisy_stdout => 1, + noisy_stderr => 1, + expects_stdin => 1, + system => $system, + is_binary => $is_binary, + ); + } + OVH::Bastion::set_terminal_mode_for_plugin(plugin => $osh_command, action => 'restore'); + + if (defined $log_insert_id and defined $log_db_name) { + $logret = OVH::Bastion::log_access_update( + insert_id => $log_insert_id, + db_name => $log_db_name, + uniq_id => $log_uniq_id, + returnvalue => $fnret->value ? $fnret->value->{'sysret'} : undef, + plugin_stdout => $fnret->value ? $fnret->value->{'stdout'} : undef, + plugin_stderr => $fnret->value ? $fnret->value->{'stderr'} : undef + ); + $logret or osh_warn($logret->msg); + } + exit($fnret->value ? $fnret->value->{'status'} : OVH::Bastion::EXIT_EXEC_FAILED); + } + else { + if ($fnret->err eq 'KO_UNKNOWN_PLUGIN') { + help(); + main_exit OVH::Bastion::EXIT_UNKNOWN_COMMAND, "unknown_command", $fnret->msg; + } + main_exit OVH::Bastion::EXIT_RESTRICTED_COMMAND, "restricted_command", $fnret->msg; + } +} + +# +# Else, it's a ttyrec ssh or telnet connection +# + +if (!$quiet) { + if ($config->{'displayLastLogin'}) { + osh_info($lastlogmsg); + print "\n"; + } + + osh_warn($msg_to_print_delayed) if defined $msg_to_print_delayed; # set if we had an error to print previously +} + +# if no user yet, fix it to remote user +# do that here, cause sometimes we do not want to pass user to osh +$user = $user || $config->{'defaultLogin'} || $remoteself || $sysself; + +# log request +osh_debug("final request : " . "$user\@$ip -p $port -- $command'\n"); + +my $displayLine = "$hostfrom:$portfrom => $self\@$bastionhost:$bastionport => $user\@$hostto:$port"; + +if (!$quiet) { + print "$displayLine ...\n"; +} + +# before doing stuff, check if we have the right to connect somewhere (some users are locked only to osh commands) +$fnret = OVH::Bastion::account_config(account => $self, key => "osh_only"); +if ($fnret and $fnret->value() =~ /yes/) { + $fnret = R('KO_ACCESS_DENIED', msg => "You don't have the right to connect anywhere"); +} +else { + $fnret = OVH::Bastion::is_access_granted(account => $self, user => $user, ipfrom => $ipfrom, ip => $ip, port => $port, wantKeys => 1); +} + +# so in the end, can we access the requested user@host machine ? +my $JITMFARequired; +if (!$fnret) { + + # User is not allowed, exit + my $message = $fnret->msg; + if ($user eq $self) { + $message .= " (tried with remote user '$user')"; # "root is not the default login anymore" + } + + my $logret = OVH::Bastion::log_access_insert( + account => $self, + cmdtype => $telnet ? 'telnet' : 'ssh', + allowed => 0, + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => $ip, + hostto => $hostto, + portto => $port, + user => $user, + params => $command, + uniqid => $log_uniq_id + ); + if (!$logret) { + osh_warn($logret); + } + + main_exit OVH::Bastion::EXIT_ACCESS_DENIED, 'access_denied', $message; +} + +# else, keep calm and carry on +# build ttyrec command that'll prefix the real command +my $ttyrec_fnret = OVH::Bastion::build_ttyrec_cmdline( + ip => $ip, + port => $port, + user => $user, + account => $self, + uniqid => $log_uniq_id, + home => $home, + realm => $realm, + remoteaccount => $remoteself, + debug => $osh_debug, + tty => $tty, + notty => $notty +); +main_exit(OVH::Bastion::EXIT_TTYREC_CMDLINE_FAILED, "ttyrec_failed", $ttyrec_fnret->msg) if !$ttyrec_fnret; + +my @ttyrec = @{$ttyrec_fnret->value->{'cmd'}}; +my $saveFile = $ttyrec_fnret->value->{'saveFile'}; + +print " allowed ... log on($saveFile)\n\n" if !$quiet; + +# now build the real command +my @command; + +# if we want telnet (not ssh) +if ($telnet) { + + # TELNET PASSWORD AUTOLOGIN + if ($userPasswordClue) { + my $fnretpass = OVH::Bastion::get_passfile(hint => $userPasswordClue, context => $userPasswordContext, self => ($remoteself || $sysself), tryLegacy => 1); + if (!$fnretpass) { + main_exit OVH::Bastion::EXIT_PASSFILE_NOT_FOUND, "passfile-not-found", $fnretpass->msg; + } + $passwordFile = $fnretpass->value; + osh_debug("going to use telnet with this password file : $passwordFile"); + print " will use TELNET with password autologin\n\n" unless $quiet; + push @command, $OVH::Bastion::BASEPATH . '/bin/shell/autologin', 'telnet', $user, $ip, $port, $passwordFile, ($timeout ? $timeout : 45); + } + + # TELNET PASSWORD INTERACTIVE + else { + print " will use TELNET with interactive password login\n\n" unless $quiet; + push @command, '/usr/bin/telnet', '-l', $user, $host, $port; + } +} + +# if we want ssh (not telnet) +else { + my @preferredAuths; + + # SSH PASSWORD AUTOLOGIN + if ($userPasswordClue) { + + push @preferredAuths, 'keyboard-interactive'; + push @preferredAuths, 'password'; + + my $fnretpass = OVH::Bastion::get_passfile(hint => $userPasswordClue, context => $userPasswordContext, self => ($remoteself || $sysself), tryLegacy => 1); + if (!$fnretpass) { + main_exit OVH::Bastion::EXIT_PASSFILE_NOT_FOUND, "passfile-not-found", $fnretpass->msg; + } + $passwordFile = $fnretpass->value; + osh_debug("going to use ssh with this password file : $passwordFile"); + print " will use SSH with password autologin\n\n" unless $quiet; + push @command, $OVH::Bastion::BASEPATH . '/bin/shell/autologin', 'ssh', $user, $ip, $port, $passwordFile, ($timeout ? $timeout : 45); + + } + + # SSH EGRESS KEYS (and maybe password interactive as a fallback if passwordAllowed) + else { + + # ssh by key + push @preferredAuths, 'publickey'; + + # also set kbdinteractive if allowed in bastion config (needed for e.g. TOTP) + push @preferredAuths, 'keyboard-interactive' if ($config->{'keyboardInteractiveAllowed'} && $userKbdInteractive); + + # also set password if allowed in bastion config (to allow users to enter a remote password interactively) + push @preferredAuths, 'password' if $config->{'passwordAllowed'}; + + push @command, '/usr/bin/ssh', $ip, '-l', $user, '-p', $port; + + my @keysToTry; + print " will try the following accesses you have: \n" unless $quiet; + foreach my $access (@{$fnret->value || []}) { + foreach my $key (@{$access->{'sortedKeys'} || []}) { + my $keyinfo = $access->{'keys'}{$key}; + my $type = $access->{'type'} . " of " . $access->{'group'}; + if ($access->{'type'} =~ /^group/) { + $type = colored($access->{'type'}, $access->{'type'} eq 'group-member' ? 'green' : 'yellow'); + $type .= " of " . colored($access->{'group'}, 'blue bold'); + } + elsif ($access->{'type'} =~ /^personal/) { + $type = colored($access->{'type'}, 'red') . ' access'; + } + my $generated = strftime("[%Y/%m/%d]", localtime($keyinfo->{'mtime'})); + + if ((not $useKey) || ($useKey eq $keyinfo->{'fingerprint'})) { + my $forced = ' '; + if ($useKey) { + $forced = colored(' (KEY FORCED ON CMDLINE)', 'bold red'); + } + elsif ($access->{'forceKey'}) { + $forced = colored(' (KEY FORCED IN ACL)', 'bold red'); + } + if ($access->{'mfaRequired'} && $access->{'mfaRequired'} ne 'none') { + $forced .= colored(' (MFA REQUIRED: ' . uc($access->{'mfaRequired'}) . ')', 'bold red'); + $JITMFARequired = $access->{'mfaRequired'}; + } + printf(" - %s with %s-%s key %s %s%s\n", $type, $keyinfo->{'family'}, $keyinfo->{'size'}, $keyinfo->{'fingerprint'}, $generated, $forced) unless $quiet; + push @keysToTry, $keyinfo->{'fullpath'} if not(grep { $_ eq $keyinfo->{'fullpath'} } @keysToTry); + } + } + } + if ($useKey and not @keysToTry) { + print " >>> No key matched the fingerprint you gave me ($useKey), connection will fail!\n"; + } + print "\n" unless $quiet; + + foreach (@keysToTry) { + if (-r) { + osh_debug("Got a group key $_"); + push @command, '-i', $_; + } + else { + osh_warn("Weird, key file $_ is not accessible"); + } + } + } + + if ($verbose) { + foreach (1 .. $verbose) { + push @command, '-v'; + } + } + push @command, '-q' if $quiet; + push @command, '-t' if $tty; + push @command, '-T' if $notty; + push @command, '-o', "ConnectTimeout=$timeout" if $timeout; + + if (not $quiet) { + $fnret = OVH::Bastion::account_config(account => $self, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1); + if ($fnret && $fnret->value =~ /yes/) { + osh_debug("Acccount is immune to idle"); + } + else { + if ($config->{'idleLockTimeout'}) { + print(" /!\\ Your session will be locked after " . $config->{'idleLockTimeout'} . " seconds of inactivity, use `--osh unlock' to unlock it\n"); + } + if ($config->{'idleKillTimeout'}) { + print(" /!\\ Your session will be killed after " . $config->{'idleKillTimeout'} . " seconds of inactivity.\n"); + } + print "\n" if ($config->{'idleLockTimeout'} || $config->{'idleKillTimeout'}); + } + } + + push @command, '-o', 'PreferredAuthentications=' . (join(',', @preferredAuths)); + + if ($config->{'sshClientHasOptionE'}) { + push @command, '-E', $saveFile . '.sshdebug'; + } + + if ($config->{'sshClientDebugLevel'}) { + foreach (1 .. $config->{'sshClientDebugLevel'}) { + push @command, '-v'; + } + } + + if ($netconf) { + + # in netconf mode, we must ask our ssh to request remote netconf subsystem + push @command, '-s', 'netconf'; + } + elsif ($command) { + + # the '--' is to force ssh (started by ttyrec (started by us)) to stop processing its options and pass the rest to remote shell + push @command, '--', $command; + } +} + +# add remoteUser as LC_BASTION to be passed via ssh +$ENV{'LC_BASTION'} = $self; + +if (!@command) { + main_exit OVH::Bastion::EXIT_UNKNOWN_COMMAND, "empty_command", "Found no command to execute!"; +} +else { + + # the '--' is to force ttyrec (started by us) to stop processing its options and execute the rest as is + push @ttyrec, '--', @command; +} + +# add binding IP if specified +# works for ssh *and* telnet +if ($bind) { + push @command, '-b', $bind; +} + +osh_debug("about to exec: " . join(' ', @ttyrec)); + +# if --wait is specified, we wait for the host to be alive before connecting +if ($wait) { + my $startedat = time(); + osh_info "Pinging $host, will connect as soon as it's alive..."; + while (1) { + my @pingcmd = qw{ fping -- }; + push @pingcmd, $host; + + my $fnretexec = OVH::Bastion::execute(cmd => \@pingcmd, noisy_stdout => 1, noisy_stderr => 1); + $fnretexec or exit(OVH::Bastion::EXIT_EXEC_FAILED); + if ($fnretexec->value->{'sysret'} == 0) { + osh_info "Alive after waiting for " . (time() - $startedat) . " seconds, connecting..."; + sleep 2 if (time() > $startedat + 1); # so that ssh has the time to startup... hopefully + last; + } + sleep 1; + } +} + +my $logret = OVH::Bastion::log_access_insert( + account => $self, + cmdtype => $telnet ? 'telnet' : 'ssh', + allowed => 1, + ipfrom => $ipfrom, + hostfrom => $hostfrom, + portfrom => $portfrom, + bastionhost => $bastionhost, + bastionip => $bastionip, + bastionport => $bastionport, + ipto => $ip, + hostto => $hostto, + portto => $port, + user => $user, + params => join(' ', @ttyrec), + ttyrecfile => $saveFile, + uniqid => $log_uniq_id +); +if (!$logret) { + osh_warn($logret); + if ($ip eq '127.0.0.1') { + osh_warn("Would deny access on out of space condition but you're root\@127.0.0.1, I hope you're here to fix me!"); + } + else { + main_exit OVH::Bastion::EXIT_OUT_OF_SPACE, 'out_of_space', "Bastion is out of space, admin intervention is needed! (" . $logret->msg . ")"; + } + $logret->{'value'} = {}; +} + +# if we have JIT MFA, do it now +if ($JITMFARequired) { + my $skipMFA = 0; + print "As this is required for this host, entering MFA phase.\n"; + if ($JITMFARequired eq 'totp' && !$isMfaTOTPConfigured) { + if ($hasMfaTOTPBypass) { + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_TOTP_SETUP_REQUIRED, + 'mfa_totp_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before connecting to this host,\nplease use the `--osh selfMFASetupTOTP' option to do so"); + } + } + elsif ($JITMFARequired eq 'password' && !$isMfaPasswordConfigured) { + if ($hasMfaPasswordBypass) { + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_PASSWORD_SETUP_REQUIRED, + 'mfa_password_setup_required', + "Sorry, but you need to setup the Multi-Factor Authentication before connecting to this host,\nplease use the `--osh selfMFASetupPassword' option to do so"); + } + } + elsif ($JITMFARequired eq 'any' && !$isMfaTOTPConfigured && !$isMfaPasswordConfigured) { + if ($hasMfaPasswordBypass || $hasMfaTOTPBypass) { + + # FIXME: should actually be $hasMFABypassAll (not yet implemented) + $skipMFA = 1; + } + else { + main_exit(OVH::Bastion::EXIT_MFA_ANY_SETUP_REQUIRED, 'mfa_any_setup_required', +"Sorry, but you need to setup the Multi-Factor Authentication before connecting to this host,\nplease use either the `--osh selfMFASetupPassword' or the `--osh selfMFASetupTOTP' option, at your discretion, to do so" + ); + } + } + + if ($skipMFA) { + print "... skipping as your account is exempt from MFA\n"; + } + else { + # use system() instead of OVH::Bastion::execute() because we need it to grab the term + my $pamtries = 3; + while (1) { + my $pamsysret = system('pamtester', 'sshd', $sysself, 'authenticate'); + if ($pamsysret < 0) { + main_exit(OVH::Bastion::EXIT_MFA_FAILED, 'mfa_failed', "MFA is required for this host, but this bastion is missing the `pamtester' tool, aborting"); + } + elsif ($pamsysret != 0) { + if (--$pamtries <= 0) { + main_exit(OVH::Bastion::EXIT_MFA_FAILED, 'mfa_failed', "Sorry, but Multi-Factor Authentication failed, I can't connect you to this host"); + } + next; + } + + # success, if we are configured to launch a external command on pamtester success, do it. + # see the bastion.conf.dist file for usage example. + my $MFAPostCommand = OVH::Bastion::config('MFAPostCommand')->value; + if (ref $MFAPostCommand eq 'ARRAY' && @$MFAPostCommand) { + s/%ACCOUNT%/$self/g for @$MFAPostCommand; + $fnret = OVH::Bastion::execute(cmd => $MFAPostCommand, must_succeed => 1); + if (!$fnret) { + warn_syslog("MFAPostCommand returned a non-zero value: " . $fnret->msg); + } + } + last; + } + } +} + +# here is a nice hack to drastically improve the memory footprint of an +# heavily used bastion. we exec() another script that is way lighter, see +# comments in the connect.pl file for more information. + +if (!$quiet) { + print "Connecting...\n"; +} + +push @toExecute, $OVH::Bastion::BASEPATH . '/bin/shell/connect.pl'; +exec( + @toExecute, $ip, $config->{'sshClientHasOptionE'}, $userPasswordClue, $saveFile, + $logret->value->{'insert_id'}, $logret->value->{'db_name'}, $logret->value->{'uniq_id'}, @ttyrec +) or exit(OVH::Bastion::EXIT_EXEC_FAILED); + +exit OVH::Bastion::EXIT_OK; + +# +# FUNCTIONS follow +# + +# +# On SIG, still try to log in db +# +sub exit_sig { + my ($sig) = @_; + if (defined $log_insert_id and defined $log_db_name) { + OVH::Bastion::log_access_update( + insert_id => $log_insert_id, + db_name => $log_db_name, + uniq_id => $log_uniq_id, + returnvalue => -9999, + comment => 'signal_' . $sig + ); + } + exit OVH::Bastion::EXIT_OK; +} + +# +# Display help message +# +sub help { + +=cut just to debug memory fingerprint + use Devel::Size qw[total_size]; + my %siz; + foreach (keys %::main::main::) + { + push @{ $siz{ total_size( $::main::main::{ $_ } ) } }, $_; + } + foreach (sort { $a <=> $b } keys %siz) + { + printf "%9d: %s\n", $_, join(' ', @{$siz{$_}}); + } + exit OVH::Bastion::EXIT_OK; +=cut + + print STDERR <<"EOF" ; + +The Bastion v$OVH::Bastion::VERSION quick usage examples: + + Connect to a server: $bastionName admin\@srv1.example.org + Run a command on a server: $bastionName admin\@srv1.example.org -- uname -a + + List the osh commands: $bastionName --osh help + Help on a specific osh command: $bastionName --osh OSH_COMMAND --help + Enter interactive mode for osh: $bastionName -i + + Get more complete help: $bastionName --long-help + +EOF + return; +} + +sub long_help { + print STDERR <<"EOF" ; + +Usage (ssh): $bastionName [OPTIONS] [user\@]host [-- REMOTE_COMMAND] +Usage (telnet): $bastionName -e [OPTIONS] [user\@]host +Usage (osh cmd): $bastionName --osh [OSH_COMMAND] [OSH_OPTIONS] + +[OPTIONS] + --host, -h HOST Host to connect to + --user, -u USER Remote host user to connect as + --port, -p PORT Port to use + --telnet, -e Use telnet instead of ssh + --timeout DELAY Specify a timeout for ssh or telnet egress connection + --bind IP Force binding of the egress ssh connection to a specified local IP + --password GROUP Use a group egress password instead of ssh keys to login (via ssh or telnet) + --self-password, -P Use your own personal account egress password instead of ssh keys to login (via ssh or telnet) + --osh Use an osh command (see --osh help to get a list) + --interactive, -i Enter interactive mode (useful to use multiple osh commands) + --quiet, -q Disable most messages and colors, useful for scripts + --always-escape Bypass config and force the bugged behavior of old bastions for REMOTE_COMMAND escaping. Don't use. + --never-escape Bypass config and force the new behavior of new bastions for REMOTE_COMMAND escaping. Don't use. + --wait Ping the host before connecting to it (useful to ssh just after a reboot!) + --long-help Print this + +[REMOTE_COMMAND] + You can pass a command to execute on the remote machine. For complex commands, don't forget + that your shell will eat one level of quotes and backslashes. One working example: + $bastionName srv1.example.org -- "perl -e 'use Data::Dumper; print Dumper(\\\@ARGV)' one 'two is 2' three" + +[OPTIONS (ssh)] : + --verbose, -v Enable verbose ssh + --tty, -t Force tty allocation + --no-tty, -T Prevent tty allocation + --use-key FP Explicitely specify the fingerprint of the egress key you want to use + --kbd-interactive Enable the keyboard-interactive authentication scheme on egress connection + --netconf Request to use netconf subsystem + +[OPTIONS (osh cmd)] + --json Return data in json format between JSON_START and JSON_END tags + --json-pretty Prettify returned json, useful for debug / human reading + --json-greppable Return data in json format squashed on one line starting with JSON_DATA= + +[OSH_COMMAND] + These are used to interact with the bastion configuration, accesses, + keys, accounts and groups. To get a list, + use: $bastionName --osh help + +[OSH_OPTIONS] + Those options are specific for each OSH_COMMAND, to get help on those, + use: $bastionName --osh OSH_COMMAND --help + +EOF + if (OVH::Bastion::is_admin(account => $self)) { + print STDERR <<"EOF" ; +[ADMIN_OPTIONS] + --ssh-as ACCOUNT Impersonate another account to ssh connect somewhere on his or her behalf. This is logged. + +EOF + } + return; +} diff --git a/bin/shell/pam_exec_pwd_info.sh b/bin/shell/pam_exec_pwd_info.sh new file mode 100755 index 0000000..653a0db --- /dev/null +++ b/bin/shell/pam_exec_pwd_info.sh @@ -0,0 +1,17 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# +# this script can be called by pam during sshd login, when negotiating MFA. +# it'll show in how many days the user password will expire. +# it can be called this way: +# +#auth optional pam_exec.so quiet debug stdout /opt/bastion/bin/shell/pam_exec_pwd_info.sh + +[ -n "$PAM_USER" ] || exit 0 +exp_date=$(chage -l "$PAM_USER" 2>/dev/null | grep 'Password expires' | cut -d: -f2-) +exp_date=$(date -d "$exp_date" +'%Y/%m/%d' 2>/dev/null) +[ -n "$exp_date" ] || exit 0 +exp=$(date -d "$exp_date" +'%s') +now=$(date +'%s') +daysleft=$(( (exp - now) / 86400 )) +echo "Your password expires on $exp_date, in $daysleft days" diff --git a/bin/sudogen/generate-sudoers.sh b/bin/sudogen/generate-sudoers.sh new file mode 100755 index 0000000..50a2ed8 --- /dev/null +++ b/bin/sudogen/generate-sudoers.sh @@ -0,0 +1,131 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck disable=SC2119 +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +type="$1" +name="$2" + +die_usage() { + echo "Usage: $0 [name]" >&2 + exit 1 +} + +generate_account_sudoers() +{ + account="$1" + if ! getent passwd "$account" | grep -q ":$basedir/bin/shell/osh.pl$"; then + action_error "$account is not a bastion account" + return 1 + fi + dst="$SUDOERS_DIR/osh-account-$account" + if [ -e "$dst" ]; then + action_detail "... overwriting $dst" + else + action_detail "... generating $dst" + fi + # normalized account only contain [A-Z0-9_], case sensitive + normalized_account=$(sed -re 's/[^A-Z0-9_]/_/gi' <<< "$account") + # as we're reducing the amount of possible chars in normalized_account + # we could have collisions: use MD5 to generate a uniq suffix + account_suffix=$(md5sum - <<< "$account" | cut -c1-6) + normalized_account="${normalized_account}_${account_suffix}" + # lowercase is prohibited + normalized_account=$(tr '[:lower:]' '[:upper:]' <<< "$normalized_account") + # to avoid race conditions between this generation and master/slave sync, + # first prepare our file as a .tmp (sudo ignores files containing a '.') + touch "${dst}.tmp" + chmod 0440 "${dst}.tmp" + { + echo "# generated from install script" + for template in $(find "$basedir/etc/sudoers.account.template.d/" -type f | sort) + do + echo + echo "# $template:" + perl -pe "s!%ACCOUNT%!$account!g;s!%NORMACCOUNT%!$normalized_account!g;s!%BASEPATH%!$basedir!g" "$template" + done + } > "${dst}.tmp" + # then move the file to its final name (potentially overwritting a previous file of the same name) + mv -f "${dst}.tmp" "$dst" + return 0 +} + +generate_group_sudoers() +{ + group="$1" + if ! test -f "/home/$group/allowed.ip"; then + action_error "$group doesn't seem to be a valid bastion group" + return 1 + fi + if ! getent group "$group-gatekeeper" >/dev/null; then + action_error "$group doesn't have a $group-gatekeeper counterpart" + return 1 + fi + dst="$SUDOERS_DIR/osh-group-$group" + if [ -e "$dst" ]; then + action_detail "... overwriting $dst" + else + action_detail "... generating $dst" + fi + # to avoid race conditions between this generation and master/slave sync, + # first prepare our file as a .tmp (sudo ignores files containing a '.') + touch "${dst}.tmp" + chmod 0440 "${dst}.tmp" + { + echo "# generated from install script" + for template in $(find "$basedir/etc/sudoers.group.template.d/" -type f | sort) + do + echo + echo "# $template:" + perl -pe "s!%GROUP%!$group!g;s!%BASEPATH%!$basedir!g" "$template" + done + } > "${dst}.tmp" + # then move the file to its final name (potentially overwritting a previous file of the same name) + mv -f "${dst}.tmp" "$dst" + return 0 +} + +if [ -z "$type" ]; then + die_usage +fi + +nbfailed=0 +if [ "$type" = group ]; then + if [ -z "$name" ]; then + action_doing "Regenerating all groups sudoers files from templates" + for group in $(getent group | cut -d: -f1 | grep -- '-gatekeeper$' | sed -e 's/-gatekeeper$//'); do + generate_group_sudoers "$group" || nbfailed=$((nbfailed + 1)) + done + else + action_doing "Regenerating group '$name' sudoers file from templates" + generate_group_sudoers "$name" || nbfailed=$((nbfailed + 1)) + fi + if [ "$nbfailed" != 0 ]; then + action_error "Failed generating $nbfailed sudoers" + else + action_done + fi + exit $nbfailed +elif [ "$type" = account ]; then + if [ -z "$name" ]; then + action_doing "Regenerating all accounts sudoers files from templates" + for account in $(getent passwd | grep ":$basedir/bin/shell/osh.pl$" | cut -d: -f1); do + generate_account_sudoers "$account"|| nbfailed=$((nbfailed + 1)) + done + else + action_doing "Regenerating account '$name' sudoers file from templates" + generate_account_sudoers "$name"|| nbfailed=$((nbfailed + 1)) + fi + if [ "$nbfailed" != 0 ]; then + action_error "Failed generating $nbfailed sudoers" + else + action_done + fi + exit $nbfailed +fi + +die_usage diff --git a/contrib/libterm-readline-gnu-perl-jessiefix.patch b/contrib/libterm-readline-gnu-perl-jessiefix.patch new file mode 100644 index 0000000..1af005f --- /dev/null +++ b/contrib/libterm-readline-gnu-perl-jessiefix.patch @@ -0,0 +1,14 @@ +--- /usr/lib/x86_64-linux-gnu/perl5/5.20/Term/ReadLine/Gnu/XS.pm 2014-08-15 14:13:27.000000000 +0200 ++++ /usr/lib/x86_64-linux-gnu/perl5/5.20/Term/ReadLine/Gnu/XS.pm 2011-11-15 04:36:04.000000000 +0100 +@@ -581,7 +580,10 @@ + return undef unless defined $_matches[0]; + } + +- return $_matches[$_i]; ++ for (; $_i <= $#_matches; $_i++) { ++ return $_matches[$_i] if ($_matches[$_i] =~ /^\Q$text/); ++ } ++ return undef; + } + + 1; diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md new file mode 100644 index 0000000..bf5004f --- /dev/null +++ b/doc/CHANGELOG.md @@ -0,0 +1,2 @@ +## v3.00.00 - 2020/xx/xx +- First public release \o/ diff --git a/doc/HIERARCHY.md b/doc/HIERARCHY.md new file mode 100644 index 0000000..a91bf5e --- /dev/null +++ b/doc/HIERARCHY.md @@ -0,0 +1,33 @@ +man 7 hier +========== + +The OVH::Bastion directory hierarchy is organized as follows: + +- bin + - bin/admin: scripts that are supposed to be launched manually by an admin where needed + - bin/cron: scripts that are launched from cronjobs + - bin/proxy: the http proxy daemon and worker live here + - bin/dev: scripts that are useful when developing for the bastion + - bin/helper: modules that are called under sudo by the plugins, to execute privileged operations + - bin/plugin: base directory (must not contain any files) of the plugins that can be used with —osh + - bin/plugin/admin: plugins that can only be launched by bastion admins + - bin/plugin/group-aclkeeper: plugins that can only be launched by group aclkeepers + - bin/plugin/group-gatekeeper: plugins that can only be launched by group gatekeepers + - bin/plugin/group-owner: plugins that can only be launched by group owners + - bin/plugin/open: plugins that can be launched by any user + - bin/plugin/restricted: plugins that can be launched only by users that are explicitely granted on said plugins + - bin/shell: where resides the main script that is declared as the shell of the bastion users, with some of its helpers + - bin/sudogen: where resides the helper script that generate group and account sudoers files + - bin/other: other helper scripts for various tasks +- contrib: placeholder directory with a readme file that references other repositories of interest when integrating the bastion in your company +- doc: sysadmin-proof documentation folder, the main Markdown files you need are there, just one `view` appart + - doc/sphinx: more complete documentation using the `sphinx` documentation system, the built version is viewable on https://ovh.github.io/bastion +- docker: where the Dockerfiles reside +- etc: contains all the template configuration files that will be installed on your system (depending on your `install` options) +- install: where optional modules can push their install script to be called by the main install script +- lib + - lib/perl: where all the Perl libraries live, used everywhere in the main code + - lib/shell: where all the Bash libraries live, usually sourced by Bash scripts +- tests + - tests/functional: contains all the tools to manage the functional testing framework + - tests/unit: where the unit tests live diff --git a/doc/UPGRADE_SPECIFIC.md b/doc/UPGRADE_SPECIFIC.md new file mode 100644 index 0000000..676112f --- /dev/null +++ b/doc/UPGRADE_SPECIFIC.md @@ -0,0 +1,3 @@ +# Specific upgrade instructions + +(none) diff --git a/doc/VERSIONING.md b/doc/VERSIONING.md new file mode 100644 index 0000000..5dbcd2f --- /dev/null +++ b/doc/VERSIONING.md @@ -0,0 +1,29 @@ +Versioning logic +================ + +The bastion version is of the format: `X.YY.ZZ`, and loosely respects the `semver` rules. + +- The `ZZ`part is considered a minor update, with no new features (or really tiny ones) and is mainly meant for bugfixes. +Update between a previous `ZZ` version is supposed to be frictionless. + +- The `YY` part is considered a major update, potentially with new features (and new bugs!). +Be sure to read the UPGRADE.md documentation which might contain instructions for a smoother update. +If no specific instruction can be found, it means there's no specific action to be taken, +appart from following the usual update process. +If the change introduces an incompatibility between a `master` and its `slave`s, +it'll be detailed in the UPGRADE.md file. + +- The `X` part is considered a massive ugrade, and requires special attention. +Be sure to read the UPGRADE.md documentation that will contain extensive information about the upgrade. +Note that it might be more complicated to rollback as massive upgrades might change the bastion on-disk file formats. +Most of the time, `master` and `slaves` won't be compatible across `X` versions. + +- Occasionally, *release candidates* will be released, which will append `-rcW` suffixes to the above version format, +with `W` being a simple incrementing number. +To ensure the version ordering is always correct, the *release candidates* of a version +will always be named with the version number minus one `Z`. +For example, if `v5.17.14` is the current version, and the `v5.18.00` is the future to-be-released update, +and we want release candidates for this version, the first release candidate will be named `v5.17.99-rc1`. +The first release candidate of `v6.00.00` will, in the same way, be named `v5.99.99-rc1`. + +- Each release is tagged with the version number, prepended by a `v`, such as `v1.23.45` diff --git a/doc/sphinx-plugins-override/accountInfo.rst b/doc/sphinx-plugins-override/accountInfo.rst new file mode 100644 index 0000000..5808d6b --- /dev/null +++ b/doc/sphinx-plugins-override/accountInfo.rst @@ -0,0 +1,45 @@ +Output example +============== + +:: + + ~ user1 is a bastion admin + ~ user1 is a bastion superowner + ~ user1 is a bastion auditor + ~ user1 has access to the following restricted commands: + ~ - accountCreate + ~ - accountDelete + ~ - groupCreate + ~ - groupDelete + ~ + ~ This account is part of the following groups: + ~ testgroup1 Owner GateKeeper ACLKeeper Member - + ~ gatekeeper-grp2 Owner GateKeeper - - - + ~ + ~ This account is active + ~ This account is not expired + ~ As a consequence, this account can connect to this bastion + ~ + ~ This account has already been used at least once + ~ Last seen on Wed 2020-07-15 12:06:27 UTC (00:00:00 ago) + ~ + ~ Account egress SSH config: + ~ - (default) + ~ + ~ PIV-enforced policy for ingress keys on this account is enabled + ~ + ~ Account Multi-Factor Authentication status: + ~ - Additional password authentication is not required for this account + ~ - Additional password authentication bypass is disabled for this account + ~ - Additional password authentication is enabled and active + ~ - Additional TOTP authentication is not required for this account + ~ - Additional TOTP authentication bypass is disabled for this account + ~ - Additional TOTP authentication is disabled + ~ - MFA policy on personal accesses (using personal keys) on egress side is: password + + ~ Account PAM UNIX password information (used for password MFA): + ~ - Password is set + ~ - Password was last changed on 2020-04-27 + ~ - Password must be changed every 90 days at least + ~ - A warning is displayed 75 days before expiration + ~ - Account will not be disabled after password expiration diff --git a/doc/sphinx-plugins-override/groupCreate.override.rst b/doc/sphinx-plugins-override/groupCreate.override.rst new file mode 100644 index 0000000..281e5dd --- /dev/null +++ b/doc/sphinx-plugins-override/groupCreate.override.rst @@ -0,0 +1,55 @@ +Create a new bastion group +========================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupCreate --group NAME --owner ACCOUNT --algo ALGO --size SIZE [OPTIONS] + +.. program:: groupCreate + + +.. option:: --group NAME + + Group name to create, NAME must contain only valid UNIX group name characters + +.. option:: --owner ACCOUNT + + Account to set as the group owner, this account will have complete rights to manage the group + +.. option:: --algo ALGO + + Specifies the algo of the key, usually either rsa, ecdsa or ed25519. Note that the available algorithms depend on the OS the bastion is running on, along with its configuration policies + +.. option:: --size SIZE + + Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (any value above 4096 is probably not very useful). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + +.. option:: --encrypted + + When specified, a passphrase will be prompted for the new key, and the private key will be stored encrypted on the bastion. Note that the passphrase will be required each time you want to use the key. + +.. option:: --no-key + + No egress keypair will be generated. In that case, omit ``--algo`` and ``--size``. + +Algorithms guideline +==================== + +A quick overview of the different algorithms:: + + +---------+------+-----------+---------+-----------------------------------------+ + | algo | size | strength | speed | compatibility | + +=========+======+===========+=========+=========================================+ + | DSA | any | 0 | n/a | obsolete, do not use | + | RSA | 2048 | ** | ** | works everywhere | + | RSA | 4096 | *** | * | works almost everywhere | + | ECDSA | 521 | **** | ***** | OpenSSH 5.7+ (debian 7+, ubuntu 12.04+) | + | ED25519 | 256 | ***** | ***** | OpenSSH 6.5+ (debian 8+, ubuntu 14.04+) | + +---------+------+-----------+---------+-----------------------------------------+ + +This table is meant as a quick cheat-sheet, you're warmly advised to do your own research, as other constraints may apply to your environment. diff --git a/doc/sphinx-plugins-override/groupInfo.rst b/doc/sphinx-plugins-override/groupInfo.rst new file mode 100644 index 0000000..5d32159 --- /dev/null +++ b/doc/sphinx-plugins-override/groupInfo.rst @@ -0,0 +1,22 @@ +Output example +============== + +:: + + ~ Group mygroup's Owners are: user1 + ~ Group mygroup's GateKeepers (managing the members/guests list) are: user2 + ~ Group mygroup's ACLKeepers (managing the group servers list) are: user3 + ~ Group mygroup's Members (with access to ALL the group servers) are: user4 + ~ Group mygroup's Guests (with access to SOME of the group servers) are: user5 + ~ + ~ The public key of this group is: + ~ + ~ fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] + ~ keyline follows, please copy the *whole* line: + from="203.0.113.4/32,192.0.2.0/26" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdD60bA3NgaOpRLgcACWfKcAMRQQRyFMppwp5GpHLTB mygroup@testbastion:1523886640 + +The first paragraph of the output lists the differents roles along with the people having these roles. + +You can also see the public egress key of this group, i.e. the key that needs to be added to the remote servers' ``authorized_keys`` files, so that ``members`` of this group can access these servers. + +Note that if you want to see the list of servers pertaining to this group, you can use the command ``groupListServers``. diff --git a/doc/sphinx-plugins-override/help.rst b/doc/sphinx-plugins-override/help.rst new file mode 100644 index 0000000..70f82cf --- /dev/null +++ b/doc/sphinx-plugins-override/help.rst @@ -0,0 +1,16 @@ +Displays help about the available plugins callable with ``--osh``. + +If you need help on a specific plugin, you can use ``--osh PLUGIN --help``, replacing ``PLUGIN`` with the actual plugin name. + +Note that if you want some help about the bastion (and not specifically about the plugins), you should use ``--help`` (without ``--osh``). + +Colors +====== + +You'll notice that plugins are hilighted in different colors, these indicate the access level needed to run the plugin. Note that plugins you don't have access to are simply omitted. + +- green (``open``): these plugins can be called by anybody +- blue (``restricted``): these plugins can only be called by users having the specific right to call them. This right is granted per plugin by the ``accountGrantCommand`` plugin +- orange (``group-gatekeeper`` and ``group-aclkeeper``): these plugins can either be called by group gatekeepers or group aclkeepers. For clarity, the same color has been used for both cases +- purple (``group-owner``): these plugins can only be called by group owners +- red (``admin``): these plugins can only be called by bastion admins diff --git a/doc/sphinx-plugins-override/info.rst b/doc/sphinx-plugins-override/info.rst new file mode 100644 index 0000000..7f36f97 --- /dev/null +++ b/doc/sphinx-plugins-override/info.rst @@ -0,0 +1,45 @@ +Output example +============== + +:: + + ~ You are user1 + ~ + ~ Your alias to connect to this bastion is: + ~ alias bastion='ssh user1@testbastion.example.org -p 22 -t -- ' + ~ Your alias to connect to this bastion with MOSH is: + ~ alias bastionm='mosh --ssh="ssh -p 22 -t" user1@testbastion.example.org -- ' + ~ + ~ Multi-Factor Authentication (MFA) on your account: + ~ - Additional password authentication is not required + ~ - Additional password authentication bypass is disabled + ~ - Additional password authentication is enabled and active + ~ - Additional TOTP authentication is not required + ~ - Additional TOTP authentication bypass is disabled + ~ - Additional TOTP authentication is disabled + ~ + ~ I am testbastion-a.example.org, aka bastion + ~ I have 42 registered accounts and 46 groups + ~ I am a MASTER, which means I accept modifications + ~ The networks I'm able to connect you to on the egress side are: all + ~ The networks that are explicitely forbidden on the egress side are: none + ~ My egress connection IP to remote servers is 192.0.2.45/32 + ~ ...don't forget to whitelist me in your firewalls! + ~ + ~ The following policy applies on this bastion: + ~ - The interactive mode (-i) is ENABLED + ~ - The support of mosh is ENABLED + ~ - Account expiration is DISABLED + ~ - Keyboard input idle time for session locking is DISABLED + ~ - Keyboard input idle time for session killing is DISABLED + ~ - The forced "from" prepend on ingress keys is DISABLED + ~ - The following algorithms are allowed for ingress SSH keys: rsa, ecdsa, ed25519 + ~ - The RSA key size for ingress SSH keys must be between 2048 and 8192 bits + ~ - The following algorithms are allowed for egress SSH keys: rsa, ecdsa, ed25519 + ~ - The RSA key size for egress SSH keys must be between 2048 and 8192 bits + ~ - The Multi-Factor Authentication (MFA) policy is ENABLED + ~ + ~ Here is your excuse for anything not working today: + ~ BOFH excuse #444: + ~ overflow error in /dev/null + diff --git a/doc/sphinx-plugins-override/lock.rst b/doc/sphinx-plugins-override/lock.rst new file mode 100644 index 0000000..dca2229 --- /dev/null +++ b/doc/sphinx-plugins-override/lock.rst @@ -0,0 +1,3 @@ +This command will lock all your current sessions on this bastion instance. Note that this only applies to the bastion instance you're launching this command on, not on the whole bastion cluster (if you happen to have one). + +To undo this action, you can use ``--osh unlock`` on the same instance. diff --git a/doc/sphinx-plugins-override/nc.rst b/doc/sphinx-plugins-override/nc.rst new file mode 100644 index 0000000..0bc62d6 --- /dev/null +++ b/doc/sphinx-plugins-override/nc.rst @@ -0,0 +1 @@ +Note that this is not a full-featured ``netcat``, we just test whether a remote port is open. There is no way to exchange data using this command. diff --git a/doc/sphinx-plugins-override/scp.override.rst b/doc/sphinx-plugins-override/scp.override.rst new file mode 100644 index 0000000..9c04c1d --- /dev/null +++ b/doc/sphinx-plugins-override/scp.override.rst @@ -0,0 +1,24 @@ +Transfer files from/to remote servers through the bastion +========================================================= + +.. note:: + + This plugin generates a valid helper script for you to use the bastion over scp, read below to learn how to use it. + +To be able to use ``scp`` over the bastion, you need to have a helper script that is specific to your account on the bastion. This plugin's job is to generate it for you. You can simply run it, and follow the guidelines. + +Once this is done, you'll be able to ``scp`` through the bastion by adding ``-S SCP_SCRIPT`` to your regular scp command, where ``SCP_SCRIPT`` is the location of the script you've just generated. + +For example, to upload a file:: + + scp -S SCP_SCRIPT localfile login@server:/dest/folder/ + +Or to recursively download a folder contents:: + + scp -S SCP_SCRIPT -r login@server:/src/folder/ /tmp/ + +Please note that you need to be granted for uploading or downloading files +with SCP to/from the remote host, in addition to having the right to SSH to it. +For a group, the right should be added with --scpup/--scpdown of the groupAddServer command. +For a personal access, the right should be added with --scpup/--scpdown of the selfAddPersonalAccess command. + diff --git a/doc/sphinx-plugins-override/selfGenerateEgressKey.override.rst b/doc/sphinx-plugins-override/selfGenerateEgressKey.override.rst new file mode 100644 index 0000000..85f3c1d --- /dev/null +++ b/doc/sphinx-plugins-override/selfGenerateEgressKey.override.rst @@ -0,0 +1,43 @@ +Create a new egress key pair on your account +============================================ + + +.. admonition:: usage + :class: cmdusage + + --osh selfGenerateEgressKey --algo ALGO --size SIZE [--encrypted] + +.. program:: selfForgetHostKey + + +.. option:: --algo ALGO + + Specifies the algo of the key, usually either rsa, ecdsa or ed25519. Note that the available algorithms depend on the OS the bastion is running on, along with its configuration policies + +.. option:: --size SIZE + + Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (any value above 4096 is probably not very useful). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + +.. option:: --encrypted + + When specified, a passphrase will be prompted for the new key, and the private key will be stored encrypted on the bastion. Note that the passphrase will be required each time you want to use the key. + +Algorithms guideline +==================== + +A quick overview of the different algorithms:: + + +---------+------+-----------+---------+-----------------------------------------+ + | algo | size | strength | speed | compatibility | + +=========+======+===========+=========+=========================================+ + | DSA | any | 0 | n/a | obsolete, do not use | + | RSA | 2048 | ** | ** | works everywhere | + | RSA | 4096 | *** | * | works almost everywhere | + | ECDSA | 521 | **** | ***** | OpenSSH 5.7+ (debian 7+, ubuntu 12.04+) | + | ED25519 | 256 | ***** | ***** | OpenSSH 6.5+ (debian 8+, ubuntu 14.04+) | + +---------+------+-----------+---------+-----------------------------------------+ + +This table is meant as a quick cheat-sheet, you're warmly advised to do your own research, as other constraints may apply to your environment. diff --git a/doc/sphinx/Makefile b/doc/sphinx/Makefile new file mode 100644 index 0000000..99143c5 --- /dev/null +++ b/doc/sphinx/Makefile @@ -0,0 +1,29 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +all: plugins default + +default: Makefile + @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + @rsync -a --delete _build/html/ ../../docs/ + @echo "HTML documentation copied to ../../docs/" + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +plugins: + @bash build-plugins-help.sh + +.PHONY: help Makefile plugins all + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/sphinx/_static/css/thebastion.css b/doc/sphinx/_static/css/thebastion.css new file mode 100644 index 0000000..10cf121 --- /dev/null +++ b/doc/sphinx/_static/css/thebastion.css @@ -0,0 +1,11 @@ +@import 'theme.css'; + +.cmdusage .last { + font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",Courier,monospace; + white-space: nowrap; + font-size: 75%; +} + +.wy-nav-content { + max-width: 1000px; +} diff --git a/doc/sphinx/build-plugins-help.sh b/doc/sphinx/build-plugins-help.sh new file mode 100644 index 0000000..5b85068 --- /dev/null +++ b/doc/sphinx/build-plugins-help.sh @@ -0,0 +1,85 @@ +#! /usr/bin/env bash +set -u +cd $(dirname $0)/../.. || exit 1 + +print_title() { + title="$1" + titlelength=$(echo "$title" | wc -c) + for i in $(seq 1 $titlelength) + do + echo -n '=' + done + echo + echo "$title" + for i in $(seq 1 $titlelength) + do + echo -n '=' + done + echo + echo + unset titlelength + unset title +} + +rm -rf doc/sphinx/plugins +mkdir doc/sphinx/plugins + +export PLUGIN_QUIET=1 +export PLUGIN_HELP=1 + +for pluginfile in $(find bin/plugin -executable -type f -print) +do + pluginname=$(echo "$pluginfile" | cut -d/ -f3-) + docfile="doc/sphinx/plugins/$pluginname.rst" + docdir=$(dirname "$docfile") + name=$(basename "$pluginname") + [ -d "$docdir" ] || mkdir -p "$docdir" + echo "$docfile..." + { + print_title "$name" + if [ -e "doc/sphinx-plugins-override/$name.override.rst" ]; then + cat "doc/sphinx-plugins-override/$name.override.rst" + else + perl "$pluginfile" '' '' '' '' | perl -ne ' + if (m{^Usage: (.+)}) { print ".. admonition:: usage\n :class: cmdusage\n\n $1\n\n.. program:: '"$name"'\n\n"; } + elsif (m{^ (-[- ,a-z|/A-Z"'"'"']+) (.+)}) { print ".. option:: $1\n\n $2\n\n"; } + elsif ($l++ == 0) { chomp; print "$_\n"."="x(length($_))."\n\n"; } + else { print "$_"; } + ' + pluginret=${PIPESTATUS[0]} + if [ "$pluginret" != 100 ] && [ "$pluginret" != 0 ]; then + echo "Unexpected return code from the plugin ($pluginret), aborting!" >&2 + exit 1 + fi + if [ -e "doc/sphinx-plugins-override/$name.rst" ]; then + echo "... adding doc/sphinx-plugins-override/$name.rst" >&2 + #printf "\n.. highlight:: shell\n\n" + cat "doc/sphinx-plugins-override/$name.rst" + fi + fi + } > "$docfile" +done + +pluginindex="doc/sphinx/plugins/index.rst" +print_title "Bastion plugins" > "$pluginindex" +cat >>"$pluginindex" <> "$pluginindex" + print_title "$section plugins" > "$indexfile" + cat >>"$indexfile" <> "$indexfile" + done +done diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py new file mode 100644 index 0000000..c4901b1 --- /dev/null +++ b/doc/sphinx/conf.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. +# +# This file does only contain a selection of the most common options. For a +# full list see the documentation: +# http://www.sphinx-doc.org/en/master/config + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'The Bastion' +copyright = '2020, OVHcloud' +author = 'The Bastion Authors' + +# The short X.Y version +version = '3.00.00' +# The full version, including alpha/beta/rc tags +release = '3.00.00' + + +# -- General configuration --------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# disable smartquotes +smartquotes = False + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +html_style = 'css/thebastion.css'; + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'TheBastionDocumentation' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'TheBastion.tex', 'The Bastion Documentation', + author, 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'thebastion', 'The Bastion Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'TheBastion', 'The Bastion Documentation', + author, 'TheBastion', 'Authentication, authorization, traceability and auditability for SSH accesses.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- diff --git a/doc/sphinx/faq.rst b/doc/sphinx/faq.rst new file mode 100644 index 0000000..23ef5c9 --- /dev/null +++ b/doc/sphinx/faq.rst @@ -0,0 +1,69 @@ +=== +FAQ +=== + +"The Bastion", really? +====================== + +We've been using this software for quite a while at OVHcloud, and there it has always been known as "the bastion": nobody ever bothered to find a fancy name for it. +So, when we decided to release it in opensource, the naming problem arose. +After going through some possible names, we realized that nothing would work, as everybody would keep naming it "the bastion" anyway, so, we decided to call it just *The Bastion*. + +Why using common::sense? +======================== + +Because it's usually a good idea to ensure you use common::sense before writing code! On a more serious note, this is almost like using ``strict`` and ``warnings``, but with a very reduced memory footprint. When you run a bastion with thousands of simultaneous active sessions with that many users, it starts to matter. + +Why Perl? +========= + +There is probably and endless list of why it's the perfect language for this, and another similarly endless list of why Perl is completely irrelevant and other $COOL_LANGUAGE would be a better fit, but some "why" reasons include: + +- It works everywhere, and most OSes have it installed by default +- Perl has this cool "taint" mode that adds security to untrusted program inputs, we use this on sensitive code +- One of the design choice of The Bastion has always been to be very close to the system, leveraging some low-level Operating System functions, which are easier to interact with using a scripting language +- The Bastion has a loose origin from an old script written at OVHcloud in the early days, back when the de-facto usual language used internally was Perl + +Why not using a PKI? +==================== + +Well, you can, of course! However this is a very centralized way of managing your accesses, with all the power in the hands of whoever controls your CA. It can be a highly successful if done very carefully, with a lot of security and processes around the certificates delivery workflows. Managing a CA correctly is no joke and can bite you quite hard if done improperly. This also happens to be a somewhat recent addition to OpenSSH, and if you have a lot of heterogeneous systems to handle, this might be a no-go. You can read more about this topic here: https://www.ovh.com/blog/the-ovhcloud-bastion-part-1/ + +What does `osh` mean in ``--osh``? +================================== + +This has long been forgotten. Some people say it used to mean "Ovh SHell" at some point, but nobody knows whether it's true or just a legend. + +What are the recommended hardware specifications? +================================================= + +They're actually quite low. Down to its basics, the bastion is merely a fancy wrapper around ``ssh``, so if you have a device that handles ``ssh`` well, it'll handle the bastion just fine. + +Now to give you some data points, we've observed that 1000 concurrent users take up FIXME Gb of RAM (including the operating system's own footprint, and the usual daemons such as auditd, syslog, etc.). If you expect to get to at least hundreds of simultaneous sessions, it's advised to use SSD drives however, as the bastion workload pattern for disk I/O is a lot of random seeks, and mechanical hard drives are very bad at this. + +Can I run it under Docker in production? +======================================== + +Technically you can, but you have to think about what are the implications (this is true regardless of the containerization technology). What's important to understand is that it adds another layer of abstraction, and can give you a false sense of security. If you either have the complete control of the host running Docker (and hardened it properly), or you fully trust whoever is running the host for you, then this is fine. Otherwise, *somebody* might have access to all your keys and you have no way to know or block it. + +Note that the provided Dockerfiles are a good start, but no volumes are defined. To ensure that all the accounts don't disappear on a ``docker rm``, you would at least need to ensure that ``/home``, ``/etc/passwd``, ``/etc/shadow``, ``/etc/group``, ``/etc/gshadow`` are stored in a volume, in addition to ``/etc/bastion`` and ``/root/.gpg``. You'll also need an SSH server, obviously, and probably a ``syslog-ng`` daemon. + +.. _faq_jumphost: + +How to use The Bastion with the SSH ``ProxyCommand`` option? +============================================================ + +**tl;dr**: you can't. + +**Fast answer**: you can't, because The Bastion is not a proxy, nor what is often called an "ssh jumphost". Granted, sometimes these are also called "bastions", hence the confusion. Note that this also applies to the ``-J`` or ``JumpHost`` ssh option, which is just a simplified ``ProxyCommand``. + +**Long answer**: The Bastion is acting as a trusted party between you (the admin or the robot) and the server of the infrastructure you need to access. To achieve this, when you use the bastion to connect to the server, there are two distinct ssh connections present at the same time: + + - The ingress ssh connection, between you and the bastion. For this connection your local private ssh key is used to authenticate yourself to the bastion + - The egress ssh connection, between the bastion and the remote server you need to access. For this connection your bastion egress private ssh key (or a group egress private ssh key you're member of) is used to authenticate the bastion to the remote server + +Those two connections are distinct, and the bastion logic merges those two so that you're under the impression that you're directly connected to the remote server. There is no dynamic port forwarding happening on the bastion to enable access to the remote server from your desktop, network-wise (which is what ``JumpHost`` does). + +Using ``ProxyCommand`` with the bastion doesn't make sense because with this option, your local ssh client expects to talk the SSH dialect on the STDIN of the ProxyCommand you're giving, and it'll try to use your local SSH key to authenticate you through it, which won't work as it's only used for the ingress connection. However, when you use the usual bastion alias, in STDIN you have the remote server terminal directly, all the SSH stuff has already been done. + +Attempting to summarize this a bit would be: ``ProxyCommand`` and ``JumpHost`` are useful when the server you're trying to connect to can't be accessed *network-wise* from where you stand, and needs to be accessed through some kind of proxy instead, where The Bastion's logic is to use two distinct SSH connections, and two distinct authentication phases, with two distinct SSH keys (yours for the ingress connection, and your bastion egress key for the egress connection). diff --git a/doc/sphinx/index.rst b/doc/sphinx/index.rst new file mode 100644 index 0000000..b7b36c7 --- /dev/null +++ b/doc/sphinx/index.rst @@ -0,0 +1,51 @@ +===================================== +Welcome to The Bastion documentation! +===================================== + +.. warning:: + + This documentation is in a WIP status, some edges might be rough! + +Wait, what's a bastion exactly? (in 140-ish characters) +======================================================= + +A so-called **bastion** is a machine used as a single entry point by operational teams (such as sysadmins, developers, devops, database admins, etc.) to securely connect to other machines of an infrastructure, usually using `ssh`. + +The bastion provides mechanisms for *authentication*, *authorization*, *traceability* and *auditability* for the whole infrastructure. + +Just yet another SSH relayhost/jumphost/gateway? +************************************************ + +No, The Bastion is an entirely different beast. + +The key technical difference between those and The Bastion is that it strictly stands between you and the remote server, operating a protocol break in the process, which enables unique features such as tty recording, proper access auditability, builtin access and groups management commands, delegation of responsibilities all the way through, etc. + +Advanced uses even include doing other things than just SSHing to a remote server. + +Those wouldn't be possible with a "simple" jumphost. More technical details on the difference :ref:`here `. + +OK, tell me more! +================= + +This documentation is organized in several sections. The first one is a :doc:`presentation` of the main functionalities, principles, and use cases of the bastion. + +The second section explains how to :doc:`get the bastion running`, including how to set up a quick playground using Docker if you want to get your hands dirty quickly. + +The third section focuses on :doc:`how to use` the bastion, from the perspective of the different roles, such as bastion users, group owners, bastion admins, etc. + +.. toctree:: + :maxdepth: 2 + :caption: Table of contents + + presentation/index + installation/index + using/index + plugins/index + faq + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/doc/sphinx/installation/advanced.rst b/doc/sphinx/installation/advanced.rst new file mode 100644 index 0000000..3410743 --- /dev/null +++ b/doc/sphinx/installation/advanced.rst @@ -0,0 +1,290 @@ +===================== +Advanced Installation +===================== + +This section goes further in explaining how to setup your bastion. You should have completed the :doc:`basic installation` first. + +Encryption & signature GPG keys +=============================== + +There are 2 pairs of GPG keys being used by the bastion: + +- The *bastion GPG key* + + * The **private** key is used by the **bastion** to **sign** the ttyrec files + * The **public** key is used by the **admins** to **verify** the signature and prove non-repudiation and non-tampering of the ttyrec files + +- The *admins GPG key* + + * The **public** key is used by the **bastion** to **encrypt** the backups and the ttyrec files + * The **private** key is used by the **admins** to **decrypt** the backups when a restore operation is needed, and the ttyrec files + +Generating the bastion GPG key +****************************** + +Generate a GPG key that will be used by the bastion to sign files, this might take a while especially if the server is idle: + +.. code-block:: shell + :emphasize-lines: 1 + + /opt/bastion/bin/admin/setup-gpg.sh --generate + + gpg: directory `/root/.gnupg' created + gpg: Generating GPG key, it'll take some time. + + Not enough random bytes available. Please do some other work to give + the OS a chance to collect more entropy! (Need 39 more bytes) + ..........+++++ + gpg: /root/.gnupg/trustdb.gpg: trustdb created + gpg: key A4480F26 marked as ultimately trusted + gpg: done + gpg: checking the trustdb + gpg: 3 marginal(s) needed, 1 complete(s) needed, PGP trust model + gpg: depth: 0 valid: 1 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 1u + + Configuration file /etc/bastion/osh-encrypt-rsync.conf.d/50-gpg-bastion-key.conf updated: + 8<---8<---8<---8<---8<---8<-- + # autogenerated with /opt/bastion/bin/admin/setup-gpg.sh at Wed Mar 21 10:03:08 CET 2018 + { + "signing_key_passphrase": "************", + "signing_key": "5D3CFDFFA4480F26" + } + --->8--->8--->8--->8--->8--->8 + + Done. + +While it's working, you can proceed to the section below. + +Generating and importing the admins GPG key +******************************************* + +You should import on the bastion one or more **public** GPG keys that'll be used for encryption. If you don't already have a GPG key for this, you can generate one. As this is the admin GPG key, don't generate it on the bastion itself. On the desk of the administrator (you?), you can run for example: + +.. code-block:: shell + :emphasize-lines: 1-9 + + myname='John Doe' + email='jd@example.org' + bastion='mybastion4.example.org' + pass=`pwgen -sy 12 1` + echo "The passphrase for the key will be: $pass" + printf "Key-Type: RSA\nKey-Length: 4096\nSubkey-Type: RSA\nSubkey-Length: 4096\n" \ + "Name-Real: %s\nName-Comment: %s\nName-Email: %s\nExpire-Date: 0\n" \ + "Passphrase: %s\n%%echo Generating GPG key\n%%commit\n%%echo done\n" \ + "$myname ($bastion)" $(date +%Y) "$email" "$pass" | gpg --gen-key --batch + + The passphrase for the key will be: ************ + gpg: Generating GPG key + + Not enough random bytes available. Please do some other work to give + the OS a chance to collect more entropy! (Need 119 more bytes) + .....+++++ + + gpg: key D2BDF9B5 marked as ultimately trusted + gpg: done + +Of course, adjust the ``myname``, ``email`` and ``bastion`` variables accordingly. Write down the passphrase in a secure vault. All bastions admins will need it if they are to decrypt ttyrec files later for inspection, and also decrypt the backup should a restore be needed. When the key is done being generated, get the public key with ``gpg -a --export D2BDF9B5``, using the proper key ID that you just generated. Copy it to your clipboard, then back to the bastion, paste it at the following prompt: + +.. code-block:: shell + + /opt/bastion/bin/admin/setup-gpg.sh --import + +Also export the private admins GPG key to a secure vault (if you want the same key to be shared by the admins): + +.. code-block:: shell + + gpg --export-secret-keys --armor D2BDF9B5 + +Rotation, encryption & backup of ttyrec files +============================================= + +You should already have all the needed GPG keys at the proper places, by following "Setup the encryption & signature GPG keys" section above. + +The configuration file is located in ``/etc/bastion/osh-encrypt-rsync.conf``. +You can ignore the ``signing_key``, ``signing_key_passphrase`` and ``recipients`` options, as these have been auto-filled when you generated the GPG keys, by dropping configuration files in the ``/etc/bastion/osh-encrypt-rsync.conf.d`` directory. Any file there takes precedence over the global configuration file. + +Once you are done with you configuration, you might want to test it by running: + +.. code-block:: shell + + /opt/bastion/bin/admin/osh-encrypt-rsync.pl --config-test + +Or even go further by starting the script in dry-run mode: + +.. code-block:: shell + + /opt/bastion/bin/admin/osh-encrypt-rsync.pl --dry-run + +Configuring keys, accounts & groups remote backup +================================================= + +Everything that is needed to restore a bastion from backup (keys, accounts, groups, etc.) is backed up daily in ``/root/backups`` by default. If you followed the "Setup the encryption & signature GPG keys" section above, these backups will be encrypted automatically. + +If you want to push these backups to a remote location, which is warmly advised, you have to specify the remote location to ``scp`` the backup archives to. The configuration file is ``/etc/bastion/osh-backup-acl-keys.conf``, and you should specify the ``PUSH_REMOTE`` and ``PUSH_OPTIONS``. + +To verify that the script is correctly able to connect remotely (and also validate the remote hostkey), start the script manually: + +.. code-block:: shell + :emphasize-lines: 1 + + /opt/bastion/bin/admin/osh-backup-acl-keys.sh + + Pushing backup file (/root/backups/backup-2020-05-25.tar.gz.gpg) remotely... + backup-2020-05-25.tar.gz.gpg + 100% 21MB 20.8MB/s 00:00 + +Also verify that the extension is ``.gpg``, as seen above, which indicates that the script successfully encrypted the backup. + +Logs/Syslog +=========== + +TODO + +Clustering (High Availability) +============================== + +The bastions can work in a cluster, with N instances. In that case, there is one *master* instance, where any modification command can be used (creating accounts, deleting groups, granting accesses), and N-1 *slave* instances, where only *readonly* actions are permitted. Note that any instance can be used to connect to infrastructures, so in effect all instances can always be used at the same time. + +Setting up a slave bastion +************************** + +Before, setting up the slave bastion, you should have the two bastions up and running (follow the normal installation documentation). + +On the slave +------------ + +The sync of the ``passwd`` and ``group`` files can have adverse effects on a newly installed machine where the packages where not installed in the same order than on the master, hence having different UIDs for the same users. The following commands are known to fix all the problems that could arise in that case, on an classic Debian machine, that has ``puppet``, ``postfix``, ``ossec`` and ``bind`` installed (disregard any *file or directory not found* message): + +.. code-block:: shell + + chown -R puppet:puppet /var/lib/puppet /var/log/puppet /run/puppet + chgrp puppet /etc/puppet + chown -R postfix /var/spool/postfix /var/lib/postfix + chown root:root /var/spool/postfix + chown -R root:root /var/spool/postfix/{pid,etc,lib,dev,usr} + chgrp -R postdrop /var/spool/postfix/{public,maildrop} + chown root:postdrop /usr/sbin/postdrop /usr/sbin/postqueue + chmod g+s /usr/sbin/postdrop /usr/sbin/postqueue + chown -R ossec /var/ossec/logs /var/ossec/queue /var/ossec/stats /var/ossec/var + chgrp -R ossec /var/ossec + chown ossecr /var/ossec/queue/agent-info /var/ossec/queue/rids + chown root /var/ossec/queue/ /var/ossec/queue/alerts/execq /var/ossec/var /var/ossec/var/run + chgrp bind /var/cache/bind /var/lib/bind /etc/bind /etc/bind/named.conf.default-zones /run/named + chown -R bind:bind /etc/bind/rndc.key /run/named + chgrp allowkeeper /var/log/bastion + +Then, on the slave, set the ``readOnlySlaveMode`` option in the ``/etc/bastion/bastion.conf`` file to ``1``: + +.. code-block:: shell + + vim /etc/bastion/bastion.conf + +This will instruct the bastion to deny any modification plugin, so that changes can only be done through the master instance. + +Then, append the master bastion synchronization public SSH keyfile, found in ``~root/.ssh/id_master2slave.pub`` on the master instance, to ``~bastionsync/.ssh/authorized_keys`` on the slave, with the following prefix: ``from="IP.OF.THE.MASTER",restrict`` + +Hence the file should look like this: + + ``from="198.51.100.42",restrict ssh-ed25519 AAA[...]`` + +Note that if you're using an old OpenSSH before version 7.2, the prefix should be instead: ``from="IP.OF.THE.MASTER",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty,no-user-rc``. + +On the master +------------- + +- Check that the key setup works correctly by launching the following command under the ``root`` account: + +.. code-block:: shell + + rsync -vaA --numeric-ids --dry-run --delete --filter "merge /etc/bastion/osh-sync-watcher.rsyncfilter" --rsh "ssh -i /root/.ssh/id_master2slave" / bastionsync@IP.OF.THE.SLAVE:/ + +- Check that it's not trying to rsync too much stuff (if you have weird things in your ``/home``, you might want to edit ``/etc/bastion/osh-sync-watcher.rsyncfilter`` to exclude that stuff) + +- Once you're happy with the output, retry without the ``--dry-run`` + +- When it's done, run it immediately again to ensure it still work, because ``/etc/passwd`` and ``/etc/group`` will have been overwritten on the slave + +- Then, edit the configuration on the master: + +.. code-block:: shell + + vim /etc/bastion/osh-sync-watcher.sh + +- Then, configure the script to start on boot and start it manually: + +.. code-block:: shell + + systemctl enable osh-sync-watcher + systemctl start osh-sync-watcher + +- You can check the logs (if you configured ``syslog`` instead, which is encouraged, then the logfile depends on your syslog daemon configuration) + +.. code-block:: shell + + tail -F /var/log/bastion/osh-sync-watcher.log + +Misc +==== + +Fix buggy ReadLine under Debian Jessie +************************************** + +Unfortunately, the version of `libterm-readline-gnu-perl` of Debian Jessie is bugged. +The version of Wheezy (7) and Stretch (9) are correct, only Jessie (8) is affected. +This impacts the ``interactive`` mode of the bastion, namely the autocomplete feature, if you want to apply a quickfix on your system, you can use this: + +.. code-block:: shell + + patch -p0 -d / -r - < /opt/bastion/contrib/libterm-readline-gnu-perl-jessiefix.patch + +Now, as Debian Jessie is quite old, the proper solution is probably not to use it! + +Create SSHFP records +******************** + +If you want to use ``SSHFP`` (for a bastion, you should), generate the records and publish them in the DNS: + +.. code-block:: shell + + awk 'tolower($1)~/^hostkey$/ {system("ssh-keygen -r bastion.name -f "$2)}' /etc/ssh/sshd_config + +Harden the SSH configuration +**************************** + +You can use this script: + +.. code-block:: shell + + /opt/bastion/bin/admin/check-ssh-hardening.pl + +Note that this script doesn't check everything, just a few items. If you want a complete audit of your SSH configuration, there are other tools available. Using our SSH templates is also a good start. + +The script also supports generating custom moduli for your installation. The following command will generate moduli of 8192 bits size. Not that it'll take several hours: + +.. code-block:: shell + + /opt/bastion/bin/admin/check-ssh-hardening.pl --generate-moduli 8192 + +2FA root authentication +*********************** + +The bastion supports TOTP (Time-based One Time Password), to further secure high profile accesses. This section covers the configuration of 2FA root authentication on the bastion itself. TOTP can also be enabled for regular bastion users, but this is covered in another section. To enable 2FA root authentication, run on the bastion: + +.. code-block:: shell + + script -c "google-authenticator -t -Q UTF8 -r 3 -R 15 -s /var/otp/root -w 2 -e 4 -D" /root/qrcode + +Of course, you can check the ``--help`` and adjust the options accordingly. The example given above has sane defaults, but you might want to adjust if needed. +Now, flash this QR code with your phone. You might want to copy the QR code somewhere safe in case you need to flash it on some other phone, by exporting the ``base64`` version of it: + +.. code-block:: shell + + gzip -c /root/qrcode | base64 -w150 + +Copy this in your password manager (for example). You can then delete the ``/root/qrcode`` file. + +TODO FIXME + +Then try to login again to the bastion as root. It should ask you a TOTP code in addition to the SSH key verification. + +In case something has gone wrong, be sure to keep your already opened existing connection to be able to fix the problem without falling back to console access. diff --git a/doc/sphinx/installation/basic.rst b/doc/sphinx/installation/basic.rst new file mode 100644 index 0000000..fe498c0 --- /dev/null +++ b/doc/sphinx/installation/basic.rst @@ -0,0 +1,163 @@ +================== +Basic Installation +================== + +If you are just upgrading from a previous version, please read :doc:`upgrading` instead. + +1. Operating system +=================== + +The following Linux distros are tested with each release, but as this is a security product, you are *warmly* advised to run it on the latest up-to-date stable version of your favorite OS: + +- Debian 10 (Buster), 9 (Stretch), 8 (Jessie) +- RHEL/CentOS 8, 7 +- Ubuntu LTS 20.04, 18.04, 16.04, 14.04* +- OpenSUSE Leap 15.1*, 15* + +\*: Note that these versions have no MFA support. + +If you're unsure or don't care, Debian is advised, as this is what we use in production at OVHcloud, hence is the most field-tested. +Any other so-called "modern" Linux distro should work with no or minor adjustments. + +The code is also known to work correctly under: + +- FreeBSD 10+ / HardenedBSD [no MFA support] + +Other BSD variants partially work, but are unsupported and discouraged as they have a severe limitation over the maximum number of supplementary groups (causing problems for group membership and restricted commands checks), no filesystem-level ACL support and missing MFA: + +- OpenBSD 5.4+ +- NetBSD 7+ + +In any case, you are expected to install this on a properly secured machine (including, but not limited to: ``iptables``/``pf``, reduced-set of installed software and daemons, general system hardening, etc.). If you use Debian, following the CIS Hardening guidelines is a good start. + +Great care has been taken to write secure, tested code, but of course this is worthless if your machine is a hacker highway. Ensuring that all the layers below the bastion code (the operating system and the hardware it's running on) is your job. + +2. Get the code +=============== + +The bastion code usually lives under ``/opt/bastion``. +You can either use ``git clone`` directly, or get the tarball of the latest release. + +- Using git: + +.. code-block:: shell + + git clone https://github.com/ovh/the-bastion /opt/bastion + git -C /opt/bastion checkout $(git -C /opt/bastion tag | tail -1) + +- Using the tarball: + +Get the tarball of the latest release, which can be found `here `_, then untar it: + +.. code-block:: shell + + test -d /opt/bastion || mkdir -p /opt/bastion + tar -C /opt/bastion v3.00.00.tar.gz + +The code supports being hosted somewhere else on the filesystem hierarchy, but this is discouraged as you might need to adjust a lot of configuration files (notably sudoers.d, cron.d, init.d) that needs an absolute path. +You should end up with directories such as ``bin``, ``lib``, etc. directly under ``/opt/bastion``. + +3. Install the needed packages +============================== + +For the supported Linux distros (see above), you can simply run: + +.. code-block:: shell + + /opt/bastion/bin/admin/packages-check.sh -i + +You can add other parameters to install optional packages, depending on your environment: + +- ``-s`` to install ``syslog-ng`` (advised, we have templates files for it) +- ``-d`` to install packages needed for developing the software (useless in production) +- ``-t`` to install ``ovh-ttyrec`` + +Note that ``-t`` makes the assumption that you have made available ``ovh-ttyrec`` to your distro repositories. If you haven't omit the ``-t`` and check the `ovh-ttyrec code repository `_'s readme section titled "*build a .deb package*" for instructions on how to do so (spoiler: it's a oneliner). + +4. Encrypt /home +================ + +Strictly speaking, this step is optional, but if you skip it, know that all the SSH private keys and session recordings will be stored unencrypted on the ``/home`` partition. Of course, if partition encryption is already handled by the OS template you use, or if the storage layer of your OS is encrypted by some other mean, you may skip this section. + +First, generate a secure password on your desk (but not too complicated so it can be typed on a console over your hypervisor over a VDI over VPN over 4G in the dark at 3am on a Sunday) and save it to a secure location: ``pwgen -s 10``. + +Then you can use the helper script to do this, it'll guide you through the process: When prompted for a passphrase, enter the one chosen just before. + +.. code-block:: shell + + /opt/bastion/bin/admin/setup-encryption.sh + +If you get a cryptsetup error, you might need to add ``--type luks1`` to the ``cryptsetup luksFormat`` command in the script. It can happen if your kernel doesn't have the necessary features enabled for LUKS2. + +.. warning:: + + Once you have setup encryption, **do not forget** to ensure that the keys backup script has encryption enabled, otherwise the backups will be stored unencrypted in ``/root/backups``, which would make your ``/home`` encryption moot. This is not covered here because you can do it later, just don't forget it: it's in the :doc:`advanced installation` section. + +5. Setup bastion and system configuration +========================================= + +The following script will do that for you. There are several possibilities here. + +- If you're installing a new machine (nobody is using it as a bastion yet), then you can regenerate brand new host keys and directly harden the ssh configuration without any side effect: + +.. code-block:: shell + + /opt/bastion/bin/admin/install --new-install + +- If you're upgrading an existing machine (from a previous version of this software), and there are already some people using it as a bastion, then if you change the host keys, they'll have to acknowledge the change when connecting, i.e. this is not transparent at all. To avoid doing that and not touching either the ssh config or the host keys, use this: + +.. code-block:: shell + + /opt/bastion/bin/admin/install --upgrade + +If you used ``--upgrade``, then you are **warmly** advised to harden the configuration yourself, using our templates as a basis. For example, if you're under Debian 10: + +.. code-block:: shell + + vimdiff /opt/bastion/etc/ssh/ssh_config.deb10 /etc/ssh/ssh_config + vimdiff /opt/bastion/etc/ssh/sshd_config.deb10 /etc/ssh/sshd_config + +There are other templates available in the same directory, for the other supported distros. + +- If you want to have a fine-grained control of what is managed by the installation script, and what is managed by yourself (or any configuration automation system you may have), you can review all the fine-grained options: + +.. code-block:: shell + + /opt/bastion/bin/admin/install --help + +6. Review the configuration +=========================== + +Base configuration files have been copied, you should review the main configuration and modify it to your needs: + +.. code-block:: shell + + vim /etc/bastion/bastion.conf + +7. Check that the code works on your machine +============================================ + +This script will verify that all required modules are installed: + +.. code-block:: shell + + /opt/bastion/bin/dev/perl-check.sh + +8. Manually create our first bastion account +============================================ + +Just launch this script, replacing *USERNAME* by the username you want to use: + +.. code-block:: shell + + /opt/bastion/bin/admin/setup-first-admin-account.sh USERNAME auto + +You'll just need to specify the public SSH key to add to this new account. It'll be created as a bastion admin, and all the restricted commands will be granted. + +.. note:: + + This command will also give you a so-called *bastion alias*, this is the command you'll routinely use to connect to the bastion, and to your infrastructures through it, replacing in effect your previous usage of the `ssh` command. The alias name advertised on account creation is configurable in ``bastion.conf``, and of course the users can rename it as they see fit, but it's advised to keep this command short, as people will use it a lot. + +If you want to create other admin accounts, you can repeat the operation. All the other accounts should be created by a bastion admin (or more precisely, by somebody granted to the *accountCreate* command), using the bastion own commands. But more about this in the section *Using the bastion*. + +Now that your bastion is installed, you can either check the :doc:`advanced installation` documentation, or head over to the :doc:`using the bastion<../using/index>` section. diff --git a/doc/sphinx/installation/docker.rst b/doc/sphinx/installation/docker.rst new file mode 100644 index 0000000..e3af07d --- /dev/null +++ b/doc/sphinx/installation/docker.rst @@ -0,0 +1,60 @@ +==================== +Sandbox using Docker +==================== + +- Let's build the docker image and run it + +.. code-block:: shell + + docker build -f docker/Dockerfile.debian10 -t bastion:debian10 . + docker run -d -p 22 --name bastiontest bastion:debian10 + +- Configure the first administrator account (get your public SSH key ready) + +.. code-block:: shell + + docker exec -it bastiontest /opt/bastion/bin/admin/setup-first-admin-account.sh poweruser auto + +- We're now up and running with the default configuration! Let's setup a handy bastion alias, and test the `info` command: + +.. code-block:: shell + + PORT=$(docker port bastiontest | cut -d: -f2) + alias bastion="ssh poweruser@127.0.0.1 -tp $PORT -- " + bastion --osh info + +- It should greet you as being a bastion admin, which means you have access to all commands. Let's enter interactive mode: + +.. code-block:: shell + + bastion -i + +- This is useful to call several `--osh` plugins in a row. Now we can ask for help to see all plugins: + +.. code-block:: shell + + $> help + +- If you have a remote machine you want to try to connect to through the bastion, fetch your egress key: + +.. code-block:: shell + + $> selfListEgressKeys + +- Copy this public key to the remote machine's `authorized_keys` under the `.ssh/` folder of the account you want to connect to, then: + +.. code-block:: shell + + $> selfAddPersonalAccess --host --user --port-any + $> ssh @ + +- Note that you can connect directly without using interactive mode, with: + +.. code-block:: shell + + bastion @ + +That's it! You can head over to the :doc:`using the bastion<../using/index>` section for more information. +Be sure to check the help of the bastion with ``bastion --help``, along with the help of each osh plugin with ``bastion --osh command --help``. + +Also don't forget to customize your ``bastion.conf`` file, which can be found in ``/etc/bastion/bastion.conf`` (for Linux). diff --git a/doc/sphinx/installation/index.rst b/doc/sphinx/installation/index.rst new file mode 100644 index 0000000..9c60734 --- /dev/null +++ b/doc/sphinx/installation/index.rst @@ -0,0 +1,11 @@ +==================== +Installation & Setup +==================== + +.. toctree:: + + basic + advanced + upgrading + docker + tests diff --git a/doc/sphinx/installation/tests.rst b/doc/sphinx/installation/tests.rst new file mode 100644 index 0000000..5ef7bab --- /dev/null +++ b/doc/sphinx/installation/tests.rst @@ -0,0 +1,34 @@ +============= +Running Tests +============= + +Using Docker +============ + +Functional tests use ``Docker`` to spawn an environment matching a bastion install. + +One of the docker instances will be used as client, which will connect to the other instance which is used as the bastion server. + +The client instance sends commands to the server instance and tests the return values against expected output. + +To test the current code, put it on a machine with docker installed, and use the following script, which will run docker build and launch the tests: + + ``tests/functional/docker/docker_build_and_run_tests.sh `` + +Where target is one of the supported OSes. Currently only Linux targets are supported. +You'll get a list of the supported targets by calling the command without argument. + +Without Docker +============== + +You can however still test the code against a BSD (or any other OS) without using Docker, by spawning a server under the target OS, and installing the bastion on it. + +Then, from another machine, run: + + ``test/functional/launch_tests_on_instance.sh [outdir]`` + +Where ``IP`` and ``port`` are the informations needed to connect to the remote server to test, ``remote_user_name`` is the name of the account created on the remote bastion to use for the tests, and ``ssh_key_path`` is the private SSH key path used to connect to the account. The ``outdir`` parameter is optional, if you want to keep the raw output of each test. + +This script is also the script used by the Docker client instance, so you're sure to get the proper results even without using Docker. + +Please do **NOT** run any of those tests on a production bastion! diff --git a/doc/sphinx/installation/upgrading.rst b/doc/sphinx/installation/upgrading.rst new file mode 100644 index 0000000..6fd6779 --- /dev/null +++ b/doc/sphinx/installation/upgrading.rst @@ -0,0 +1,48 @@ +========= +Upgrading +========= + +General upgrade instructions +============================ + +- Update the code, if you're using ``git``, you can checkout the latest tag: + +.. code-block:: shell + + ( umask 0022 && cd /opt/bastion && git fetch && git checkout $(git tag | tail -1) ) + +- Run the install script in upgrade mode, so it can make adjustments to the system needed for the new version: + +.. code-block:: shell + + /opt/bastion/bin/admin/install --upgrade + +Note that if you're using a infrastructure automation tool such as Puppet, Ansible, Chef, and don't want the update script to touch some files that you manage yourself, you can use ``--upgrade-managed``. See the ``--help`` for a more fine-grained upgrade path if needed. + +- Install any missing newly needed system package: + +.. code-block:: shell + + /opt/bastion/bin/admin/packages-check.sh + +- Check the configuration for new parameters or options you may want to adjust + +.. code-block:: shell + + for f in /opt/bastion/etc/bastion/*.dist; do vimdiff $f /etc/bastion/$(basename $f .dist); done + +- If you have some power-users and you want them to have access to any new restricted plugin this new version might have, you can run for those accounts: + +.. code-block:: shell + + /opt/bastion/bin/admin/grant-all-restricted-commands-to.sh ACCOUNTNAME + +Note that this is done automatically for bastion admins. + +Version-specific upgrade instructions +===================================== + +v3.00.00 +******** + +Initial public version, no specific upgrade instructions diff --git a/doc/sphinx/plugins/admin/adminMaintenance.rst b/doc/sphinx/plugins/admin/adminMaintenance.rst new file mode 100644 index 0000000..817de3f --- /dev/null +++ b/doc/sphinx/plugins/admin/adminMaintenance.rst @@ -0,0 +1,30 @@ +================= +adminMaintenance +================= + +Manage the bastion maintenance mode +=================================== + + +.. admonition:: usage + :class: cmdusage + + --osh adminMaintenance <--lock [--message "'reason for maintenance'"]|--unlock> + +.. program:: adminMaintenance + + +.. option:: --lock + + Set maintenance mode: new logins will be disallowed + +.. option:: --unlock + + Unset maintenance mode: new logins are allowed and the bastion functions normally + +.. option:: --message MESSAGE + + Optionally set a maintenance reason, if you're in a shell, quote it twice. + + + diff --git a/doc/sphinx/plugins/admin/adminSudo.rst b/doc/sphinx/plugins/admin/adminSudo.rst new file mode 100644 index 0000000..c538591 --- /dev/null +++ b/doc/sphinx/plugins/admin/adminSudo.rst @@ -0,0 +1,33 @@ +========== +adminSudo +========== + +Impersonate another user +======================== + + +.. admonition:: usage + :class: cmdusage + + --osh adminSudo -- --sudo-as ACCOUNT <--sudo-cmd PLUGIN -- [PLUGIN specific options...]> + +.. program:: adminSudo + + +.. option:: --sudo-as ACCOUNT + + Specify which bastion account we want to impersonate + +.. option:: --sudo-cmd PLUGIN + + --osh command we want to launch as the user (see --osh help) + + +Example:: + + --osh adminSudo -- --sudo-as user12 --sudo-cmd info -- --name somebodyelse + +Don't forget the double-double-dash as seen in the example above: one after the plugin name, +and another one to separate adminSudo options from the options of the plugin to be called. + + diff --git a/doc/sphinx/plugins/admin/index.rst b/doc/sphinx/plugins/admin/index.rst new file mode 100644 index 0000000..52632b5 --- /dev/null +++ b/doc/sphinx/plugins/admin/index.rst @@ -0,0 +1,8 @@ +============== +admin plugins +============== + +.. toctree:: + + adminMaintenance + adminSudo diff --git a/doc/sphinx/plugins/group-aclkeeper/groupAddServer.rst b/doc/sphinx/plugins/group-aclkeeper/groupAddServer.rst new file mode 100644 index 0000000..3dcb803 --- /dev/null +++ b/doc/sphinx/plugins/group-aclkeeper/groupAddServer.rst @@ -0,0 +1,72 @@ +=============== +groupAddServer +=============== + +Add an IP or IP block to a group's servers list +=============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddServer --group GROUP [OPTIONS] + +.. program:: groupAddServer + + +.. option:: --group GROUP + + Specify which group this machine should be added to (it should have the public group key of course) + +.. option:: --host HOST|IP|NET/CIDR + + Host(s) to add access to, either a HOST which will be resolved to an IP immediately, or an IP, + + or a whole network using the NET/CIDR notation +.. option:: --user USER + + Specify which remote user should be allowed (root, run, etc...) + +.. option:: --user-any + + Allow any remote user (the remote user should still have the public group key in all cases) + +.. option:: --port PORT + + Only allow access to this port (e.g. 22) + +.. option:: --port-any + + Allow access to any port + +.. option:: --scpup + + Allow SCP upload, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Allow SCP download, you<--bastion--server (omit --user in this case) + +.. option:: --force + + Don't try the ssh connection, just add the host to the group blindly + +.. option:: --force-key FINGERPRINT + + Only use the key with the specified fingerprint to connect to the server (cf groupInfo) + +.. option:: --ttl SECONDS|DURATION + + Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + +.. option:: --comment '"ANY TEXT'" + + Add a comment alongside this server + + +Examples:: + + --osh groupAddServer --group grp1 --host 203.0.113.0/24 --user-any --port-any --force --comment '"a whole network"' + --osh groupAddServer --group grp2 --host srv1.example.org --user root --port 22 + + diff --git a/doc/sphinx/plugins/group-aclkeeper/groupDelServer.rst b/doc/sphinx/plugins/group-aclkeeper/groupDelServer.rst new file mode 100644 index 0000000..c97994f --- /dev/null +++ b/doc/sphinx/plugins/group-aclkeeper/groupDelServer.rst @@ -0,0 +1,50 @@ +=============== +groupDelServer +=============== + +Remove an IP or IP block from a group's serrver list +==================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelServer --group GROUP [OPTIONS] + +.. program:: groupDelServer + + +.. option:: --group GROUP + + Specify which group this machine should be removed from + +.. option:: --host HOST|IP|NET/CIDR + + Host(s) we want to remove access to + +.. option:: --user USER + + Remote user that was allowed, if any user was allowed, use --user-any + +.. option:: --user-any + + Use if any remote login was allowed + +.. option:: --port PORT + + Remote SSH port that was allowed, if any port was allowed, use --port-any + +.. option:: --port-any + + Use if any remote port was allowed + +.. option:: --scpup + + Remove SCP upload right, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Remove SCP download right, you<--bastion--server (omit --user in this case) + + + diff --git a/doc/sphinx/plugins/group-aclkeeper/index.rst b/doc/sphinx/plugins/group-aclkeeper/index.rst new file mode 100644 index 0000000..b3007d6 --- /dev/null +++ b/doc/sphinx/plugins/group-aclkeeper/index.rst @@ -0,0 +1,8 @@ +======================== +group-aclkeeper plugins +======================== + +.. toctree:: + + groupAddServer + groupDelServer diff --git a/doc/sphinx/plugins/group-gatekeeper/groupAddGuestAccess.rst b/doc/sphinx/plugins/group-gatekeeper/groupAddGuestAccess.rst new file mode 100644 index 0000000..cefb125 --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/groupAddGuestAccess.rst @@ -0,0 +1,70 @@ +==================== +groupAddGuestAccess +==================== + +Add a specific group server access to an account +================================================ + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddGuestAccess --group GROUP --account ACCOUNT [OPTIONS] + +.. program:: groupAddGuestAccess + + +.. option:: --group GROUP + + group to add guest access to + +.. option:: --account ACCOUNT + + name of the other bastion account to add access to, he'll be given access to the GROUP key + +.. option:: --host HOST|IP + + add access to this HOST (which must belong to the GROUP) + +.. option:: --user USER + + allow connecting to HOST only with remote login USER + +.. option:: --user-any + + allow connecting to HOST with any remote login + +.. option:: --port PORT + + allow connecting to HOST only to remote port PORT + +.. option:: --port-any + + allow connecting to HOST with any remote port + +.. option:: --scpup + + allow SCP upload, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + allow SCP download, you<--bastion--server (omit --user in this case) + +.. option:: --ttl SECONDS|DURATION + + Specify a number of seconds after which the access will automatically expire + + +This command adds, to an existing bastion account, access to the egress keys of a group, +but only to accessing one or several given servers, instead of all the servers of this group. + +If you want to add complete access to an account to all the present and future servers +of the group, using the group key, please use ``groupAddMember`` instead. + +If you want to add access to an account to a group server but using his personal bastion +key instead of the group key, please use ``accountAddPersonalAccess`` instead (his public key +must be on the remote server). + +This command is the opposite of ``groupDelGuestAccess``. + + diff --git a/doc/sphinx/plugins/group-gatekeeper/groupAddMember.rst b/doc/sphinx/plugins/group-gatekeeper/groupAddMember.rst new file mode 100644 index 0000000..a57a0fe --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/groupAddMember.rst @@ -0,0 +1,31 @@ +=============== +groupAddMember +=============== + +Add an account to the member list +================================= + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddMember --group GROUP --account ACCOUNT + +.. program:: groupAddMember + + +.. option:: --group GROUP + + which group to set ACCOUNT as a member of + +.. option:: --account ACCOUNT + + which account to set as a member of GROUP + + +The specified account will be able to access all present and future servers +pertaining to this group. +If you need to give a specific and/or temporary access instead, +see ``groupAddGuestAccess`` + + diff --git a/doc/sphinx/plugins/group-gatekeeper/groupDelGuestAccess.rst b/doc/sphinx/plugins/group-gatekeeper/groupDelGuestAccess.rst new file mode 100644 index 0000000..a95c9e4 --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/groupDelGuestAccess.rst @@ -0,0 +1,62 @@ +==================== +groupDelGuestAccess +==================== + +Remove a specific group server access from an account +===================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelGuestAccess --group GROUP --account ACCOUNT [OPTIONS] + +.. program:: groupDelGuestAccess + + +.. option:: --group GROUP + + group to remove guest access from + + --account ACCOUNT name of the other bastion account to remove access from +.. option:: --host HOST|IP + + remove access from this HOST (which must belong to the GROUP) + +.. option:: --user USER + + allow connecting to HOST only with remote login USER + +.. option:: --user-any + + allow connecting to HOST with any remote login + +.. option:: --port PORT + + allow connecting to HOST only to remote port PORT + +.. option:: --port-any + + allow connecting to HOST with any remote port + +.. option:: --scpup + + allow SCP upload, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + allow SCP download, you<--bastion--server (omit --user in this case) + + +This command removes, from an existing bastion account, access to a given server, using the +egress keys of the group. The list of such servers is given by ``groupListGuestAccesses`` + +If you want to remove member access from an account to all the present and future servers +of the group, using the group key, please use ``groupDelMember`` instead. + +If you want to remove access from an account from a group server but using his personal bastion +key instead of the group key, please use ``accountDelPersonalAccess`` instead. + +This command is the opposite of ``groupAddGuestAccess``. + + diff --git a/doc/sphinx/plugins/group-gatekeeper/groupDelMember.rst b/doc/sphinx/plugins/group-gatekeeper/groupDelMember.rst new file mode 100644 index 0000000..bc46644 --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/groupDelMember.rst @@ -0,0 +1,31 @@ +=============== +groupDelMember +=============== + +Remove an account from the members list +======================================= + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelMember --group GROUP --account ACCOUNT + +.. program:: groupDelMember + + +.. option:: --group GROUP + + which group to remove ACCOUNT as a member of + +.. option:: --account ACCOUNT + + which account to remove as a member of GROUP + + +The specified account will no longerr be able to access all present and future servers +pertaining to this group. +Note that if this account also had specific guest accesses to this group, they may +still apply, see ``groupListGuestAccesses`` + + diff --git a/doc/sphinx/plugins/group-gatekeeper/groupListGuestAccesses.rst b/doc/sphinx/plugins/group-gatekeeper/groupListGuestAccesses.rst new file mode 100644 index 0000000..cf5854c --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/groupListGuestAccesses.rst @@ -0,0 +1,26 @@ +======================= +groupListGuestAccesses +======================= + +List the guest accesses to servers of a group specifically granted to an account +================================================================================ + + +.. admonition:: usage + :class: cmdusage + + --osh groupListGuestAccesses --group GROUP --account ACCOUNT + +.. program:: groupListGuestAccesses + + +.. option:: --group GROUP + + Look for accesses to servers of this GROUP + +.. option:: --account ACCOUNT + + Which account to check + + + diff --git a/doc/sphinx/plugins/group-gatekeeper/index.rst b/doc/sphinx/plugins/group-gatekeeper/index.rst new file mode 100644 index 0000000..0d3e4c4 --- /dev/null +++ b/doc/sphinx/plugins/group-gatekeeper/index.rst @@ -0,0 +1,11 @@ +========================= +group-gatekeeper plugins +========================= + +.. toctree:: + + groupAddGuestAccess + groupAddMember + groupDelGuestAccess + groupDelMember + groupListGuestAccesses diff --git a/doc/sphinx/plugins/group-owner/groupAddAclkeeper.rst b/doc/sphinx/plugins/group-owner/groupAddAclkeeper.rst new file mode 100644 index 0000000..56187e2 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupAddAclkeeper.rst @@ -0,0 +1,28 @@ +================== +groupAddAclkeeper +================== + +Add the group aclkeeper role to an account +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddAclkeeper --group GROUP --account ACCOUNT + +.. program:: groupAddAclkeeper + + +.. option:: --group GROUP + + which group to set ACCOUNT as an aclkeeper of + +.. option:: --account ACCOUNT + + which account to set as an aclkeeper of GROUP + + +The specified account will be able to manage the server list of this group + + diff --git a/doc/sphinx/plugins/group-owner/groupAddGatekeeper.rst b/doc/sphinx/plugins/group-owner/groupAddGatekeeper.rst new file mode 100644 index 0000000..a1ea975 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupAddGatekeeper.rst @@ -0,0 +1,29 @@ +=================== +groupAddGatekeeper +=================== + +Add the group gatekeeper role to an account +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddGatekeeper --group GROUP --account ACCOUNT + +.. program:: groupAddGatekeeper + + +.. option:: --group GROUP + + which group to set ACCOUNT as a gatekeeper of + +.. option:: --account ACCOUNT + + which account to set as a gatekeeper of GROUP + + +The specified account will be able to manage the members list of this group, +along with the guests list + + diff --git a/doc/sphinx/plugins/group-owner/groupAddOwner.rst b/doc/sphinx/plugins/group-owner/groupAddOwner.rst new file mode 100644 index 0000000..6de5647 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupAddOwner.rst @@ -0,0 +1,31 @@ +============== +groupAddOwner +============== + +Add the group owner role to an account +====================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupAddOwner --group GROUP --account ACCOUNT + +.. program:: groupAddOwner + + +.. option:: --group GROUP + + which group to set ACCOUNT as an owner of + +.. option:: --account ACCOUNT + + which account to set as an owner of GROUP + + +The specified account will be able to manage the owner, gatekeeper +and aclkeeper list of this group. In other words, this account will +have all possible rights to manage the group and delegate some or all +of the rights to other accounts + + diff --git a/doc/sphinx/plugins/group-owner/groupDelAclkeeper.rst b/doc/sphinx/plugins/group-owner/groupDelAclkeeper.rst new file mode 100644 index 0000000..b50d84e --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupDelAclkeeper.rst @@ -0,0 +1,28 @@ +================== +groupDelAclkeeper +================== + +Remove the group aclkeeper role from an account +=============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelAclkeeper --group GROUP --account ACCOUNT + +.. program:: groupDelAclkeeper + + +.. option:: --group GROUP + + which group to remove ACCOUNT as an aclkeeper of + +.. option:: --account ACCOUNT + + which account to remove as an aclkeeper of GROUP + + +The specified account will no longer be able to manage the server list of this group + + diff --git a/doc/sphinx/plugins/group-owner/groupDelGatekeeper.rst b/doc/sphinx/plugins/group-owner/groupDelGatekeeper.rst new file mode 100644 index 0000000..ef9bba6 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupDelGatekeeper.rst @@ -0,0 +1,29 @@ +=================== +groupDelGatekeeper +=================== + +Remove the group gatekeeper role from an account +================================================ + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelGatekeeper --group GROUP --account ACCOUNT + +.. program:: groupDelGatekeeper + + +.. option:: --group GROUP + + which group to remove ACCOUNT as a gatekeeper of + +.. option:: --account ACCOUNT + + which account to remove as a gatekeeper of GROUP + + +The specified account will no longer be able to manager the members nor +the guest list of this group + + diff --git a/doc/sphinx/plugins/group-owner/groupDelOwner.rst b/doc/sphinx/plugins/group-owner/groupDelOwner.rst new file mode 100644 index 0000000..45937c3 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupDelOwner.rst @@ -0,0 +1,29 @@ +============== +groupDelOwner +============== + +Remove the group owner role from an account +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelOwner --group GROUP --account ACCOUNT + +.. program:: groupDelOwner + + +.. option:: --group GROUP + + which group to set ACCOUNT as an owner of + +.. option:: --account ACCOUNT + + which account to set as an owner of GROUP + + +The specified account will no longer be able to manage the owner, +gatekeeper and aclkeeper lists of this group + + diff --git a/doc/sphinx/plugins/group-owner/groupGeneratePassword.rst b/doc/sphinx/plugins/group-owner/groupGeneratePassword.rst new file mode 100644 index 0000000..d7dd80e --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupGeneratePassword.rst @@ -0,0 +1,42 @@ +====================== +groupGeneratePassword +====================== + +Generate a new egress password for the group +============================================ + + +.. admonition:: usage + :class: cmdusage + + --osh groupGeneratePassword --group GROUP [--size SIZE] --do-it + +.. program:: groupGeneratePassword + + +.. option:: --group GROUP + + Specify which group you want to generate a password for + +.. option:: --size SIZE + + Specify the number of characters of the password to generate + +.. option:: --do-it + + Required for the password to actually be generated, BEWARE: please read the note below + + +Generate a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for the group, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. + + diff --git a/doc/sphinx/plugins/group-owner/groupModify.rst b/doc/sphinx/plugins/group-owner/groupModify.rst new file mode 100644 index 0000000..14afcc7 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupModify.rst @@ -0,0 +1,31 @@ +============ +groupModify +============ + +Modify the configuration of a group +=================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupModify --group GROUP [--mfa-required password|totp|any|none] [--guest-ttl-limit DURATION] + +.. program:: groupModify + + +.. option:: --group GROUP + + Name of the group to modify + +.. option:: --mfa-required password|totp|any|none + + Enforce UNIX password requirement, or TOTP requirement, or any MFA requirement, when connecting to a server of the group + +.. option:: --guest-ttl-limit DURATION + + This group will enforce TTL setting, on guest access creation, to be set, and not to a higher value than DURATION, + + set to zero to allow guest accesses creation without any TTL set (default) + + diff --git a/doc/sphinx/plugins/group-owner/groupTransmitOwnership.rst b/doc/sphinx/plugins/group-owner/groupTransmitOwnership.rst new file mode 100644 index 0000000..3b6c344 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/groupTransmitOwnership.rst @@ -0,0 +1,29 @@ +======================= +groupTransmitOwnership +======================= + +Transmit your group ownership to somebody else +============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupTransmitOwnership --group GROUP --account ACCOUNT + +.. program:: groupTransmitOwnership + + +.. option:: --group GROUP + + which group to set ACCOUNT as an owner of + +.. option:: --account ACCOUNT + + which account to set as an owner of GROUP + + +Note that this command has the same net effect than using ``groupAddOwner`` +to add ACCOUNT as an owner, then removing yourself with ``groupDelOwner`` + + diff --git a/doc/sphinx/plugins/group-owner/index.rst b/doc/sphinx/plugins/group-owner/index.rst new file mode 100644 index 0000000..809fcc6 --- /dev/null +++ b/doc/sphinx/plugins/group-owner/index.rst @@ -0,0 +1,15 @@ +==================== +group-owner plugins +==================== + +.. toctree:: + + groupAddAclkeeper + groupAddGatekeeper + groupAddOwner + groupDelAclkeeper + groupDelGatekeeper + groupDelOwner + groupGeneratePassword + groupModify + groupTransmitOwnership diff --git a/doc/sphinx/plugins/index.rst b/doc/sphinx/plugins/index.rst new file mode 100644 index 0000000..734598e --- /dev/null +++ b/doc/sphinx/plugins/index.rst @@ -0,0 +1,12 @@ +================ +Bastion plugins +================ + +.. toctree:: + + admin/index.rst + group-gatekeeper/index.rst + restricted/index.rst + group-owner/index.rst + open/index.rst + group-aclkeeper/index.rst diff --git a/doc/sphinx/plugins/open/alive.rst b/doc/sphinx/plugins/open/alive.rst new file mode 100644 index 0000000..4aaa704 --- /dev/null +++ b/doc/sphinx/plugins/open/alive.rst @@ -0,0 +1,25 @@ +====== +alive +====== + +Ping a host and exist as soon as it answers +=========================================== + + +This command can be used to monitor a host that is expected to go back online soon. +Note that if you want to ssh to it afterwards, you can simply use the ``--wait`` main option. + +.. admonition:: usage + :class: cmdusage + + --osh alive [--host] HOSTNAME + +.. program:: alive + + +.. option:: --host HOSTNAME + + hostname or IP to ping + + + diff --git a/doc/sphinx/plugins/open/batch.rst b/doc/sphinx/plugins/open/batch.rst new file mode 100644 index 0000000..fa1db84 --- /dev/null +++ b/doc/sphinx/plugins/open/batch.rst @@ -0,0 +1,39 @@ +====== +batch +====== + +Run a batch of osh commands fed through STDIN +============================================= + + +.. admonition:: usage + :class: cmdusage + + --osh batch + +.. program:: batch + + +**Examples:** + +(replace ``bssh`` by your bastion alias) + +- run 3 simple commands in a oneliner: + +:: + + printf "%b\n%b\n%b" info selfListIngressKeys selfListEgressKeys | bssh --osh batch + +- run a lot of commands written out line by line in a file: + +:: + + bssh --osh batch < cmdlist.txt + +- add 3 users to a group: + +:: + + for i in user1 user2 user3; do echo "groupAddMember --account $i --group grp4"; done | bssh --osh batch + + diff --git a/doc/sphinx/plugins/open/clush.rst b/doc/sphinx/plugins/open/clush.rst new file mode 100644 index 0000000..0fb9d85 --- /dev/null +++ b/doc/sphinx/plugins/open/clush.rst @@ -0,0 +1,38 @@ +====== +clush +====== + +Launch a remote command on several machines sequentially (clush-like) +===================================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh clush [OPTIONS] --command '"remote command"' + +.. program:: clush + + +.. option:: --list HOSTLIST + + Comma-separated list of the hosts to run the command on + +.. option:: --step-by-step + + Pause before running the command on each host + +.. option:: --no-pause-on-failure + + Don't pause if the remote command doesn't return failed (returned != 0) + +.. option:: --no-confirm + + Skip confirmation of the host list and command + +.. option:: --command '"remote cmd"' + + Command to be run on the remote hosts. If you're in a shell, quote it twice as shown. + + + diff --git a/doc/sphinx/plugins/open/groupInfo.rst b/doc/sphinx/plugins/open/groupInfo.rst new file mode 100644 index 0000000..986f231 --- /dev/null +++ b/doc/sphinx/plugins/open/groupInfo.rst @@ -0,0 +1,44 @@ +========== +groupInfo +========== + +Print some basic information about a group +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupInfo --group GROUP + +.. program:: groupInfo + + +.. option:: --group GROUP + + specify the group to display the infos of + + + +Output example +============== + +:: + + ~ Group mygroup's Owners are: user1 + ~ Group mygroup's GateKeepers (managing the members/guests list) are: user2 + ~ Group mygroup's ACLKeepers (managing the group servers list) are: user3 + ~ Group mygroup's Members (with access to ALL the group servers) are: user4 + ~ Group mygroup's Guests (with access to SOME of the group servers) are: user5 + ~ + ~ The public key of this group is: + ~ + ~ fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] + ~ keyline follows, please copy the *whole* line: + from="203.0.113.4/32,192.0.2.0/26" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdD60bA3NgaOpRLgcACWfKcAMRQQRyFMppwp5GpHLTB mygroup@testbastion:1523886640 + +The first paragraph of the output lists the differents roles along with the people having these roles. + +You can also see the public egress key of this group, i.e. the key that needs to be added to the remote servers' ``authorized_keys`` files, so that ``members`` of this group can access these servers. + +Note that if you want to see the list of servers pertaining to this group, you can use the command ``groupListServers``. diff --git a/doc/sphinx/plugins/open/groupList.rst b/doc/sphinx/plugins/open/groupList.rst new file mode 100644 index 0000000..42f5529 --- /dev/null +++ b/doc/sphinx/plugins/open/groupList.rst @@ -0,0 +1,22 @@ +========== +groupList +========== + +List the groups available on this bastion +========================================= + + +.. admonition:: usage + :class: cmdusage + + --osh groupList [--all] + +.. program:: groupList + + +.. option:: --all + + List all groups, even those to which you don't have access + + + diff --git a/doc/sphinx/plugins/open/groupListPasswords.rst b/doc/sphinx/plugins/open/groupListPasswords.rst new file mode 100644 index 0000000..668629f --- /dev/null +++ b/doc/sphinx/plugins/open/groupListPasswords.rst @@ -0,0 +1,24 @@ +=================== +groupListPasswords +=================== + +List the hashes and metadata of egress passwords of a group +=========================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupListPasswords --group GROUP + +.. program:: groupListPasswords + + +.. option:: --group GROUP + + Show the data for this group + + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH + + diff --git a/doc/sphinx/plugins/open/groupListServers.rst b/doc/sphinx/plugins/open/groupListServers.rst new file mode 100644 index 0000000..26fe9a5 --- /dev/null +++ b/doc/sphinx/plugins/open/groupListServers.rst @@ -0,0 +1,26 @@ +================= +groupListServers +================= + +List the servers (IPs and IP blocks) pertaining to a group +========================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupListServers --group GROUP [--reverse-dns] + +.. program:: groupListServers + + +.. option:: --group GROUP + + List the servers of this group + +.. option:: --reverse-dns + + Resolve and display the reverse DNS of each IP (SLOW!) + + + diff --git a/doc/sphinx/plugins/open/help.rst b/doc/sphinx/plugins/open/help.rst new file mode 100644 index 0000000..1d800c5 --- /dev/null +++ b/doc/sphinx/plugins/open/help.rst @@ -0,0 +1,33 @@ +===== +help +===== + +I'm So Meta, Even This Acronym +============================== + + +.. admonition:: usage + :class: cmdusage + + --osh help + +.. program:: help + + + +Displays help about the available plugins callable with ``--osh``. + +If you need help on a specific plugin, you can use ``--osh PLUGIN --help``, replacing ``PLUGIN`` with the actual plugin name. + +Note that if you want some help about the bastion (and not specifically about the plugins), you should use ``--help`` (without ``--osh``). + +Colors +====== + +You'll notice that plugins are hilighted in different colors, these indicate the access level needed to run the plugin. Note that plugins you don't have access to are simply omitted. + +- green (``open``): these plugins can be called by anybody +- blue (``restricted``): these plugins can only be called by users having the specific right to call them. This right is granted per plugin by the ``accountGrantCommand`` plugin +- orange (``group-gatekeeper`` and ``group-aclkeeper``): these plugins can either be called by group gatekeepers or group aclkeepers. For clarity, the same color has been used for both cases +- purple (``group-owner``): these plugins can only be called by group owners +- red (``admin``): these plugins can only be called by bastion admins diff --git a/doc/sphinx/plugins/open/index.rst b/doc/sphinx/plugins/open/index.rst new file mode 100644 index 0000000..cf4ffda --- /dev/null +++ b/doc/sphinx/plugins/open/index.rst @@ -0,0 +1,37 @@ +============= +open plugins +============= + +.. toctree:: + + alive + batch + clush + groupInfo + groupListPasswords + groupList + groupListServers + help + info + lock + mtr + nc + ping + scp + selfAddIngressKey + selfDelIngressKey + selfForgetHostKey + selfGenerateEgressKey + selfGeneratePassword + selfGenerateProxyPassword + selfListAccesses + selfListEgressKeys + selfListIngressKeys + selfListPasswords + selfListSessions + selfMFAResetPassword + selfMFAResetTOTP + selfMFASetupPassword + selfMFASetupTOTP + selfPlaySession + unlock diff --git a/doc/sphinx/plugins/open/info.rst b/doc/sphinx/plugins/open/info.rst new file mode 100644 index 0000000..19fb860 --- /dev/null +++ b/doc/sphinx/plugins/open/info.rst @@ -0,0 +1,62 @@ +===== +info +===== + +Displays some information about this bastion instance +===================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh info + +.. program:: info + + + +Output example +============== + +:: + + ~ You are user1 + ~ + ~ Your alias to connect to this bastion is: + ~ alias bastion='ssh user1@testbastion.example.org -p 22 -t -- ' + ~ Your alias to connect to this bastion with MOSH is: + ~ alias bastionm='mosh --ssh="ssh -p 22 -t" user1@testbastion.example.org -- ' + ~ + ~ Multi-Factor Authentication (MFA) on your account: + ~ - Additional password authentication is not required + ~ - Additional password authentication bypass is disabled + ~ - Additional password authentication is enabled and active + ~ - Additional TOTP authentication is not required + ~ - Additional TOTP authentication bypass is disabled + ~ - Additional TOTP authentication is disabled + ~ + ~ I am testbastion-a.example.org, aka bastion + ~ I have 42 registered accounts and 46 groups + ~ I am a MASTER, which means I accept modifications + ~ The networks I'm able to connect you to on the egress side are: all + ~ The networks that are explicitely forbidden on the egress side are: none + ~ My egress connection IP to remote servers is 192.0.2.45/32 + ~ ...don't forget to whitelist me in your firewalls! + ~ + ~ The following policy applies on this bastion: + ~ - The interactive mode (-i) is ENABLED + ~ - The support of mosh is ENABLED + ~ - Account expiration is DISABLED + ~ - Keyboard input idle time for session locking is DISABLED + ~ - Keyboard input idle time for session killing is DISABLED + ~ - The forced "from" prepend on ingress keys is DISABLED + ~ - The following algorithms are allowed for ingress SSH keys: rsa, ecdsa, ed25519 + ~ - The RSA key size for ingress SSH keys must be between 2048 and 8192 bits + ~ - The following algorithms are allowed for egress SSH keys: rsa, ecdsa, ed25519 + ~ - The RSA key size for egress SSH keys must be between 2048 and 8192 bits + ~ - The Multi-Factor Authentication (MFA) policy is ENABLED + ~ + ~ Here is your excuse for anything not working today: + ~ BOFH excuse #444: + ~ overflow error in /dev/null + diff --git a/doc/sphinx/plugins/open/lock.rst b/doc/sphinx/plugins/open/lock.rst new file mode 100644 index 0000000..a1b3b63 --- /dev/null +++ b/doc/sphinx/plugins/open/lock.rst @@ -0,0 +1,20 @@ +===== +lock +===== + +Manually lock all your current sessions +======================================= + + +.. admonition:: usage + :class: cmdusage + + --osh lock + +.. program:: lock + + + +This command will lock all your current sessions on this bastion instance. Note that this only applies to the bastion instance you're launching this command on, not on the whole bastion cluster (if you happen to have one). + +To undo this action, you can use ``--osh unlock`` on the same instance. diff --git a/doc/sphinx/plugins/open/mtr.rst b/doc/sphinx/plugins/open/mtr.rst new file mode 100644 index 0000000..958349d --- /dev/null +++ b/doc/sphinx/plugins/open/mtr.rst @@ -0,0 +1,22 @@ +==== +mtr +==== + +Runs the mtr tool to traceroute a host +====================================== + + +.. admonition:: usage + :class: cmdusage + + --osh mtr [--host] HOST [--report] + +.. program:: mtr + + +.. option:: --report + + Don't run mtr interactively, output a text report once done + + + diff --git a/doc/sphinx/plugins/open/nc.rst b/doc/sphinx/plugins/open/nc.rst new file mode 100644 index 0000000..0f3309f --- /dev/null +++ b/doc/sphinx/plugins/open/nc.rst @@ -0,0 +1,31 @@ +=== +nc +=== + +Check whether a remote TCP port is open +======================================= + + +.. admonition:: usage + :class: cmdusage + + --osh nc [--host] HOST [--port] PORT [-w TIMEOUT] + +.. program:: nc + + +.. option:: --host HOST + + Host or IP to attempt to connect to + +.. option:: --port PORT + + TCP port to attempt to connect to + +.. option:: -w SECONDS + + Timeout in seconds (default: 3) + + + +Note that this is not a full-featured ``netcat``, we just test whether a remote port is open. There is no way to exchange data using this command. diff --git a/doc/sphinx/plugins/open/ping.rst b/doc/sphinx/plugins/open/ping.rst new file mode 100644 index 0000000..f96d4fe --- /dev/null +++ b/doc/sphinx/plugins/open/ping.rst @@ -0,0 +1,34 @@ +===== +ping +===== + +Ping a remote host from the bastion +=================================== + + +.. admonition:: usage + :class: cmdusage + + --osh ping [--host HOST] [-c COUNT] [-s PKTSZ] [-t TTL] [-w TIMEOUT] + +.. program:: ping + + +.. option:: --host HOST + + Remote host to ping + +.. option:: -c COUNT + + Number of pings to send (default: infinite) + +.. option:: -t TTL + + TTL to set in the ICMP packet (default: OS dependent) + +.. option:: -w TIMEOUT + + Exit unconditionally after this amount of seconds + + + diff --git a/doc/sphinx/plugins/open/scp.rst b/doc/sphinx/plugins/open/scp.rst new file mode 100644 index 0000000..be6817c --- /dev/null +++ b/doc/sphinx/plugins/open/scp.rst @@ -0,0 +1,28 @@ +==== +scp +==== + +Transfer files from/to remote servers through the bastion +========================================================= + +.. note:: + + This plugin generates a valid helper script for you to use the bastion over scp, read below to learn how to use it. + +To be able to use ``scp`` over the bastion, you need to have a helper script that is specific to your account on the bastion. This plugin's job is to generate it for you. You can simply run it, and follow the guidelines. + +Once this is done, you'll be able to ``scp`` through the bastion by adding ``-S SCP_SCRIPT`` to your regular scp command, where ``SCP_SCRIPT`` is the location of the script you've just generated. + +For example, to upload a file:: + + scp -S SCP_SCRIPT localfile login@server:/dest/folder/ + +Or to recursively download a folder contents:: + + scp -S SCP_SCRIPT -r login@server:/src/folder/ /tmp/ + +Please note that you need to be granted for uploading or downloading files +with SCP to/from the remote host, in addition to having the right to SSH to it. +For a group, the right should be added with --scpup/--scpdown of the groupAddServer command. +For a personal access, the right should be added with --scpup/--scpdown of the selfAddPersonalAccess command. + diff --git a/doc/sphinx/plugins/open/selfAddIngressKey.rst b/doc/sphinx/plugins/open/selfAddIngressKey.rst new file mode 100644 index 0000000..c584cba --- /dev/null +++ b/doc/sphinx/plugins/open/selfAddIngressKey.rst @@ -0,0 +1,24 @@ +================== +selfAddIngressKey +================== + +Add a new ingress public key to your account +============================================ + + +.. admonition:: usage + :class: cmdusage + + --osh selfAddIngressKey [--public-key '"ssh key text"'] + +.. program:: selfAddIngressKey + + +.. option:: --public-key KEY + + Your new ingress public SSH key to deposit on the bastion, use double-quoting if your're under a shell. + + +If no option is specified, you'll be prompted interactively. + + diff --git a/doc/sphinx/plugins/open/selfDelIngressKey.rst b/doc/sphinx/plugins/open/selfDelIngressKey.rst new file mode 100644 index 0000000..094e281 --- /dev/null +++ b/doc/sphinx/plugins/open/selfDelIngressKey.rst @@ -0,0 +1,28 @@ +================== +selfDelIngressKey +================== + +Remove an ingress public key from your account +============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfDelIngressKey [--line-number-to-delete|-l NB] [--fingerprint-to-delete|-f FP] + +.. program:: selfDelIngressKey + + +.. option:: -l, --line-number-to-delete NB + + Directly specify the line number to delete (CAUTION!), you can get the line numbers with selfListIngressKeys + +.. option:: -f, --fingerprint-to-delete FP + + Directly specify the fingerprint of the key to delete (CAUTION!) + + +If none of these options are specified, you'll be prompted interactively. + + diff --git a/doc/sphinx/plugins/open/selfForgetHostKey.rst b/doc/sphinx/plugins/open/selfForgetHostKey.rst new file mode 100644 index 0000000..7aaa61f --- /dev/null +++ b/doc/sphinx/plugins/open/selfForgetHostKey.rst @@ -0,0 +1,30 @@ +================== +selfForgetHostKey +================== + +Forget a known host key from your bastion account +================================================= + + +.. admonition:: usage + :class: cmdusage + + --osh selfForgetHostKey [--host HOST] [--port PORT] + +.. program:: selfForgetHostKey + + +.. option:: --host HOST + + Host to remove from the known_hosts file + +.. option:: --port PORT + + Port to look for in the known_hosts file (default: 22) + + +This command is useful to remove the man-in-the-middle warning when a key has changed, +however please verify that the host key change is legit before using this command. +The warning SSH gives is there for a reason. + + diff --git a/doc/sphinx/plugins/open/selfGenerateEgressKey.rst b/doc/sphinx/plugins/open/selfGenerateEgressKey.rst new file mode 100644 index 0000000..33a82f9 --- /dev/null +++ b/doc/sphinx/plugins/open/selfGenerateEgressKey.rst @@ -0,0 +1,47 @@ +====================== +selfGenerateEgressKey +====================== + +Create a new egress key pair on your account +============================================ + + +.. admonition:: usage + :class: cmdusage + + --osh selfGenerateEgressKey --algo ALGO --size SIZE [--encrypted] + +.. program:: selfForgetHostKey + + +.. option:: --algo ALGO + + Specifies the algo of the key, usually either rsa, ecdsa or ed25519. Note that the available algorithms depend on the OS the bastion is running on, along with its configuration policies + +.. option:: --size SIZE + + Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (any value above 4096 is probably not very useful). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + +.. option:: --encrypted + + When specified, a passphrase will be prompted for the new key, and the private key will be stored encrypted on the bastion. Note that the passphrase will be required each time you want to use the key. + +Algorithms guideline +==================== + +A quick overview of the different algorithms:: + + +---------+------+-----------+---------+-----------------------------------------+ + | algo | size | strength | speed | compatibility | + +=========+======+===========+=========+=========================================+ + | DSA | any | 0 | n/a | obsolete, do not use | + | RSA | 2048 | ** | ** | works everywhere | + | RSA | 4096 | *** | * | works almost everywhere | + | ECDSA | 521 | **** | ***** | OpenSSH 5.7+ (debian 7+, ubuntu 12.04+) | + | ED25519 | 256 | ***** | ***** | OpenSSH 6.5+ (debian 8+, ubuntu 14.04+) | + +---------+------+-----------+---------+-----------------------------------------+ + +This table is meant as a quick cheat-sheet, you're warmly advised to do your own research, as other constraints may apply to your environment. diff --git a/doc/sphinx/plugins/open/selfGeneratePassword.rst b/doc/sphinx/plugins/open/selfGeneratePassword.rst new file mode 100644 index 0000000..fc8fd17 --- /dev/null +++ b/doc/sphinx/plugins/open/selfGeneratePassword.rst @@ -0,0 +1,38 @@ +===================== +selfGeneratePassword +===================== + +Generate a new egress password for your account +=============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfGeneratePassword [--size SIZE] --do-it + +.. program:: selfGeneratePassword + + +.. option:: --size SIZE + + Specify the number of characters of the password to generate + +.. option:: --do-it + + Required for the password to actually be generated, BEWARE: please read the note below + + +This plugin generates a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for your account, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. + + diff --git a/doc/sphinx/plugins/open/selfGenerateProxyPassword.rst b/doc/sphinx/plugins/open/selfGenerateProxyPassword.rst new file mode 100644 index 0000000..f816dbc --- /dev/null +++ b/doc/sphinx/plugins/open/selfGenerateProxyPassword.rst @@ -0,0 +1,35 @@ +========================== +selfGenerateProxyPassword +========================== + +Generate a new ingress password to use the bastion HTTPS proxy +============================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfGenerateProxyPassword [--size SIZE] --do-it + +.. program:: selfGenerateProxyPassword + + +.. option:: --size SIZE + + Size of the password to generate + +.. option:: --do-it + + Required for the password to actually be generated, BEWARE: please read the note below + + +This plugin generates a new ingress password to use the bastion HTTPS proxy. + +NOTE: this is only needed for devices that only support HTTPS API and not ssh, +in most cases you should ignore this command completely, unless you +know that devices you need to access are using an HTTPS API. + +BEWARE: once a new password is generated this way, it'll be set as the new +HTTPS proxy ingress password to use right away for your account. + + diff --git a/doc/sphinx/plugins/open/selfListAccesses.rst b/doc/sphinx/plugins/open/selfListAccesses.rst new file mode 100644 index 0000000..a86e3bd --- /dev/null +++ b/doc/sphinx/plugins/open/selfListAccesses.rst @@ -0,0 +1,28 @@ +================= +selfListAccesses +================= + +Show the list of servers you have access to +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfListAccesses [--hide-groups] [--reverse-dns] + +.. program:: selfListAccesses + + +.. option:: --hide-groups + + Don't show the machines you have access to through group rights. In other words, + + list only your private accesses. + +.. option:: --reverse-dns + + Attempt to resolve the reverse hostnames (SLOW!) + + + diff --git a/doc/sphinx/plugins/open/selfListEgressKeys.rst b/doc/sphinx/plugins/open/selfListEgressKeys.rst new file mode 100644 index 0000000..81ef656 --- /dev/null +++ b/doc/sphinx/plugins/open/selfListEgressKeys.rst @@ -0,0 +1,22 @@ +=================== +selfListEgressKeys +=================== + +List the public egress keys of your account +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfListEgressKeys + +.. program:: selfListEgressKeys + + +The keys listed are the public egress SSH keys tied to your account. +They can be used to gain access to another machine from this bastion, +by putting one of those keys in the remote machine's ``authorized_keys`` file, +and adding yourself access to this machine with ``selfAddPersonalAccess``. + + diff --git a/doc/sphinx/plugins/open/selfListIngressKeys.rst b/doc/sphinx/plugins/open/selfListIngressKeys.rst new file mode 100644 index 0000000..2fbec6d --- /dev/null +++ b/doc/sphinx/plugins/open/selfListIngressKeys.rst @@ -0,0 +1,21 @@ +==================== +selfListIngressKeys +==================== + +List the public ingress keys of your account +============================================ + + +.. admonition:: usage + :class: cmdusage + + --osh selfListIngressKeys + +.. program:: selfListIngressKeys + + +The keys listed are the public ingress SSH keys tied to your account. +Their private counterpart should be detained only by you, and used +to authenticate yourself to this bastion. + + diff --git a/doc/sphinx/plugins/open/selfListPasswords.rst b/doc/sphinx/plugins/open/selfListPasswords.rst new file mode 100644 index 0000000..e8dddbd --- /dev/null +++ b/doc/sphinx/plugins/open/selfListPasswords.rst @@ -0,0 +1,19 @@ +================== +selfListPasswords +================== + +List the hashes and metadata of the egress passwords associated to your account +=============================================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfListPasswords + +.. program:: selfListPasswords + + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH + + diff --git a/doc/sphinx/plugins/open/selfListSessions.rst b/doc/sphinx/plugins/open/selfListSessions.rst new file mode 100644 index 0000000..faa453f --- /dev/null +++ b/doc/sphinx/plugins/open/selfListSessions.rst @@ -0,0 +1,75 @@ +================= +selfListSessions +================= + +List the few past sessions of your account +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfListSessions [OPTIONS] + +.. program:: selfListSessions + + +.. option:: --detailed + + Display more informations about each session + +.. option:: --limit LIMIT + + Limit to LIMIT results + +.. option:: --id ID + + Only sessions having this ID + +.. option:: --type TYPE + + Only sessions of specified type (ssh, osh, ...) + +.. option:: --allowed + + Only sessions that have been allowed by the bastion + +.. option:: --denied + + Only sessions that have been denied by the bastion + +.. option:: --after WHEN + + Only sessions that started after WHEN, + + WHEN can be a TIMESTAMP, or YYYY-MM-DD[@HH:MM:SS] +.. option:: --before WHEN + + Only sessions that started before WHEN, + + WHEN can be a TIMESTAMP, or YYYY-MM-DD[@HH:MM:SS] +.. option:: --host HOST + + Only sessions connecting to remote HOST + +.. option:: --to-port PORT + + Only sessions connecting to remote PORT + +.. option:: --user USER + + Only sessions connecting using remote USER + +.. option:: --via HOST + + Only sessions that connected through bastion IP HOST + +.. option:: --via-port PORT + + Only sessions that connected through bastion PORT + + +Note that only the sessions that happened on this precise bastion instance will be shown, +not the sessions from its possible cluster siblings. + + diff --git a/doc/sphinx/plugins/open/selfMFAResetPassword.rst b/doc/sphinx/plugins/open/selfMFAResetPassword.rst new file mode 100644 index 0000000..fe237c0 --- /dev/null +++ b/doc/sphinx/plugins/open/selfMFAResetPassword.rst @@ -0,0 +1,20 @@ +===================== +selfMFAResetPassword +===================== + +Remove the UNIX password of your account +======================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfMFAResetPassword + +.. program:: selfMFAResetPassword + + +Note that if your password is set, you'll be prompted for it. +Also note that this doesn't remove your UNIX password requirement, if set (see ``accountModify`` for this). + + diff --git a/doc/sphinx/plugins/open/selfMFAResetTOTP.rst b/doc/sphinx/plugins/open/selfMFAResetTOTP.rst new file mode 100644 index 0000000..4d2463e --- /dev/null +++ b/doc/sphinx/plugins/open/selfMFAResetTOTP.rst @@ -0,0 +1,20 @@ +================= +selfMFAResetTOTP +================= + +Remove the TOTP configuration of your account +============================================= + + +.. admonition:: usage + :class: cmdusage + + --osh selfMFAResetTOTP + +.. program:: selfMFAResetTOTP + + +Note that if your TOTP is set, you'll be prompted for it. +Also note that this doesn't remove your TOTP requirement, if set (see accountModify for this). + + diff --git a/doc/sphinx/plugins/open/selfMFASetupPassword.rst b/doc/sphinx/plugins/open/selfMFASetupPassword.rst new file mode 100644 index 0000000..f18144a --- /dev/null +++ b/doc/sphinx/plugins/open/selfMFASetupPassword.rst @@ -0,0 +1,22 @@ +===================== +selfMFASetupPassword +===================== + +Setup an additional credential (UNIX password) to access your account +===================================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfMFASetupPassword [--yes] + +.. program:: selfMFASetupPassword + + +.. option:: --yes + + Don't ask for confirmation + + + diff --git a/doc/sphinx/plugins/open/selfMFASetupTOTP.rst b/doc/sphinx/plugins/open/selfMFASetupTOTP.rst new file mode 100644 index 0000000..eb95c61 --- /dev/null +++ b/doc/sphinx/plugins/open/selfMFASetupTOTP.rst @@ -0,0 +1,22 @@ +================= +selfMFASetupTOTP +================= + +Setup an additional credential (TOTP) to access your account +============================================================ + + +.. admonition:: usage + :class: cmdusage + + --osh selfMFASetupTOTP [--no-confirm] + +.. program:: selfMFASetupTOTP + + +.. option:: --no-confirm + + Bypass the confirmation step for TOTP enrollment phase + + + diff --git a/doc/sphinx/plugins/open/selfPlaySession.rst b/doc/sphinx/plugins/open/selfPlaySession.rst new file mode 100644 index 0000000..0a8c4d2 --- /dev/null +++ b/doc/sphinx/plugins/open/selfPlaySession.rst @@ -0,0 +1,22 @@ +================ +selfPlaySession +================ + +Replay the ttyrec of a past session +=================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfPlaySession --id ID + +.. program:: selfPlaySession + + +.. option:: --id ID + + ID of the session to replay, use ``selfListSessions`` to find it. + + + diff --git a/doc/sphinx/plugins/open/unlock.rst b/doc/sphinx/plugins/open/unlock.rst new file mode 100644 index 0000000..81d0297 --- /dev/null +++ b/doc/sphinx/plugins/open/unlock.rst @@ -0,0 +1,22 @@ +======= +unlock +======= + +Unlock all your current sessions +================================ + + +.. admonition:: usage + :class: cmdusage + + --osh unlock + +.. program:: unlock + + +This command will unlock all your current sessions on this bastion instance, +that were either locked for inactivity timeout or manually locked by you with ``lock``. +Note that this only applies to the bastion instance you're launching this +command on, not on the whole bastion cluster (if you happen to have one). + + diff --git a/doc/sphinx/plugins/restricted/accountAddPersonalAccess.rst b/doc/sphinx/plugins/restricted/accountAddPersonalAccess.rst new file mode 100644 index 0000000..57d19ce --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountAddPersonalAccess.rst @@ -0,0 +1,65 @@ +========================= +accountAddPersonalAccess +========================= + +Add a personal server access to an account +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountAddPersonalAccess --account ACCOUNT --host HOST [OPTIONS] + +.. program:: accountAddPersonalAccess + + +.. option:: --account + + Bastion account to add the access to + +.. option:: --host IP|HOST|IP/MASK + + Server to add access to + +.. option:: --user USER + + Remote login to use, if you want to allow any login, use --user-any + +.. option:: --user-any + + Allow access with any remote login + +.. option:: --port PORT + + Remote SSH port to use, if you want to allow any port, use --port-any + +.. option:: --port-any + + Allow access to all remote ports + +.. option:: --scpup + + Allow SCP upload, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Allow SCP download, you<--bastion--server (omit --user in this case) + +.. option:: --force-key FINGERPRINT + + Only use the key with the specified fingerprint to connect to the server (cf selfListEgressKeys) + +.. option:: --ttl SECONDS|DURATION + + Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + +.. option:: --comment "'ANY TEXT'" + + Add a comment alongside this server. Quote it twice as shown if you're under a shell. + + +The access will work only if one of the account's personal egress public key has been copied to the remote server. +To get the list of an account's personal egress public keys, see ``accountListEgressKeyss`` and ``selfListEgressKeys``. + + diff --git a/doc/sphinx/plugins/restricted/accountCreate.rst b/doc/sphinx/plugins/restricted/accountCreate.rst new file mode 100644 index 0000000..3bf2e51 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountCreate.rst @@ -0,0 +1,60 @@ +============== +accountCreate +============== + +Create a new bastion account +============================ + + +.. admonition:: usage + :class: cmdusage + + --osh accountCreate --account ACCOUNT [OPTIONS] + +.. program:: accountCreate + + +.. option:: --account NAME + + Account name to create, NAME must contain only valid UNIX account name characters + +.. option:: --uid UID + + Account system UID, also see --uid-auto + +.. option:: --uid-auto + + Auto-select an UID from the allowed range (the upper available one will be used) + +.. option:: --always-active + + This account's activation won't be challenged on connection, even if the bastion is globally + + configured to check for account activation +.. option:: --osh-only + + This account will only be able to use OSH commands, and not connecting to machines (ssh or telnet) + +.. option:: --immutable-key + + Deny any subsequent modification of the account key (selfAddKey and selfDelKey are denied) + +.. option:: --comment '"STRING"' + + An optional comment when creating the account. Quote it twice as shown if you're under a shell. + +.. option:: --public-key '"KEY"' + + Account public SSH key to deposit on the bastion, if not present, + + you'll be prompted interactively for it. Quote it twice as shown if your're under a shell. +.. option:: --no-key + + Don't prompt for an SSH key, no ingress public key will be installed + +.. option:: --ttl SECONDS|DURATION + + Time after which the account will be deactivated (amount of seconds, or duration string such as "4d12h15m") + + + diff --git a/doc/sphinx/plugins/restricted/accountDelPersonalAccess.rst b/doc/sphinx/plugins/restricted/accountDelPersonalAccess.rst new file mode 100644 index 0000000..8eca52c --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountDelPersonalAccess.rst @@ -0,0 +1,50 @@ +========================= +accountDelPersonalAccess +========================= + +Remove a personal server access from an account +=============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountDelPersonalAccess --account ACCOUNT --host HOST [OPTIONS] + +.. program:: accountDelPersonalAccess + + +.. option:: --account + + Bastion account to remove access from + +.. option:: --host IP|HOST|IP/MASK + + Server to remove access from + +.. option:: --user USER + + Remote user that was allowed, if any user was allowed, use --user-any + +.. option:: --user-any + + Use if any remote login was allowed + +.. option:: --port PORT + + Remote SSH port that was allowed, if any port was allowed, use --port-any + +.. option:: --port-any + + Use if any remote port was allowed + +.. option:: --scpup + + Remove SCP upload right, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Remove SCP download right, you<--bastion--server (omit --user in this case) + + + diff --git a/doc/sphinx/plugins/restricted/accountDelete.rst b/doc/sphinx/plugins/restricted/accountDelete.rst new file mode 100644 index 0000000..63d2c00 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountDelete.rst @@ -0,0 +1,26 @@ +============== +accountDelete +============== + +Delete an account from the bastion +================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountDelete --account ACCOUNT + +.. program:: accountDelete + + +.. option:: --account ACCOUNT + + Account name to delete + +.. option:: --no-confirm + + Don't ask for confirmation, and blame yourself if you deleted the wrong account + + + diff --git a/doc/sphinx/plugins/restricted/accountGeneratePassword.rst b/doc/sphinx/plugins/restricted/accountGeneratePassword.rst new file mode 100644 index 0000000..e8c2216 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountGeneratePassword.rst @@ -0,0 +1,42 @@ +======================== +accountGeneratePassword +======================== + +Generate a new egress password for an account +============================================= + + +.. admonition:: usage + :class: cmdusage + + --osh accountGeneratePassword --account ACCOUNT [--size SIZE] --do-it + +.. program:: accountGeneratePassword + + +.. option:: --account ACCOUNT + + Specify which account you want to generate a password for + +.. option:: --size SIZE + + Specify the number of characters of the password to generate + +.. option:: --do-it + + Required for the password to actually be generated, BEWARE: please read the note below + + +This plugin generates a new egress password to be used for ssh or telnet + +NOTE: this is only needed for devices that don't support key-based SSH, +in most cases you should ignore this command completely, unless you +know that devices you need to access only support telnet or password-based SSH. + +BEWARE: once a new password is generated this way, it'll be set as the new +egress password to use right away for the account, for any access that requires it. +A fallback mechanism exists that will auto-try the previous password if this one +doesn't work, but please ensure that this new password is deployed on the remote +devices as soon as possible. + + diff --git a/doc/sphinx/plugins/restricted/accountGrantCommand.rst b/doc/sphinx/plugins/restricted/accountGrantCommand.rst new file mode 100644 index 0000000..ca5163f --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountGrantCommand.rst @@ -0,0 +1,33 @@ +==================== +accountGrantCommand +==================== + +Grant access to a restricted command +==================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountGrantCommand --account ACCOUNT --command COMMAND + +.. program:: accountGrantCommand + + +.. option:: --account ACCOUNT + + Bastion account to work on + +.. option:: --command COMMAND + + The name of the OSH plugin to grant (omit to get the list) + + +Note that accountGrantCommand being a restricted command as any other, you can grant it to somebody else, +but then they'll be able to grant themselves or anybody else to this or any other restricted command. + +A specific command that can be granted is ``auditor``, it is not an osh plugin per-se, but activates +more verbose output for several other commands, suitable to audit rights or grants without needing +to be granted (e.g. to groups). + + diff --git a/doc/sphinx/plugins/restricted/accountInfo.rst b/doc/sphinx/plugins/restricted/accountInfo.rst new file mode 100644 index 0000000..3c83c14 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountInfo.rst @@ -0,0 +1,67 @@ +============ +accountInfo +============ + +Display some information about an account +========================================= + + +.. admonition:: usage + :class: cmdusage + + --osh accountInfo --account ACCOUNT + +.. program:: accountInfo + + +.. option:: --account ACCOUNT + + The account name to work on + + + +Output example +============== + +:: + + ~ user1 is a bastion admin + ~ user1 is a bastion superowner + ~ user1 is a bastion auditor + ~ user1 has access to the following restricted commands: + ~ - accountCreate + ~ - accountDelete + ~ - groupCreate + ~ - groupDelete + ~ + ~ This account is part of the following groups: + ~ testgroup1 Owner GateKeeper ACLKeeper Member - + ~ gatekeeper-grp2 Owner GateKeeper - - - + ~ + ~ This account is active + ~ This account is not expired + ~ As a consequence, this account can connect to this bastion + ~ + ~ This account has already been used at least once + ~ Last seen on Wed 2020-07-15 12:06:27 UTC (00:00:00 ago) + ~ + ~ Account egress SSH config: + ~ - (default) + ~ + ~ PIV-enforced policy for ingress keys on this account is enabled + ~ + ~ Account Multi-Factor Authentication status: + ~ - Additional password authentication is not required for this account + ~ - Additional password authentication bypass is disabled for this account + ~ - Additional password authentication is enabled and active + ~ - Additional TOTP authentication is not required for this account + ~ - Additional TOTP authentication bypass is disabled for this account + ~ - Additional TOTP authentication is disabled + ~ - MFA policy on personal accesses (using personal keys) on egress side is: password + + ~ Account PAM UNIX password information (used for password MFA): + ~ - Password is set + ~ - Password was last changed on 2020-04-27 + ~ - Password must be changed every 90 days at least + ~ - A warning is displayed 75 days before expiration + ~ - Account will not be disabled after password expiration diff --git a/doc/sphinx/plugins/restricted/accountList.rst b/doc/sphinx/plugins/restricted/accountList.rst new file mode 100644 index 0000000..c3c0ffe --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountList.rst @@ -0,0 +1,30 @@ +============ +accountList +============ + +List the bastion accounts +========================= + + +.. admonition:: usage + :class: cmdusage + + --osh accountList [--account ACCOUNT] [--inactive-only] [--audit] + +.. program:: accountList + + +.. option:: --account ACCOUNT + + Only list the specified account. This is an easy way to check whether the account exists + +.. option:: --inactive-only + + Only list inactive accounts + +.. option:: --audit + + Show more verbose information (SLOW!), you need to be a bastion auditor + + + diff --git a/doc/sphinx/plugins/restricted/accountListAccesses.rst b/doc/sphinx/plugins/restricted/accountListAccesses.rst new file mode 100644 index 0000000..eba9f71 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountListAccesses.rst @@ -0,0 +1,33 @@ +==================== +accountListAccesses +==================== + +View the expanded access list of a given bastion account +======================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountListAccesses --account ACCOUNT [--hide-groups] [--reverse-dns] + +.. program:: accountListAccesses + + +.. option:: --account ACCOUNT + + The account to work on + + +.. option:: --hide-groups + + Don't show the machines the accouns has access to through group rights. + + In other words, list only the account's private accesses. + +.. option:: --reverse-dns + + Attempt to resolve the reverse hostnames (SLOW!) + + + diff --git a/doc/sphinx/plugins/restricted/accountListEgressKeys.rst b/doc/sphinx/plugins/restricted/accountListEgressKeys.rst new file mode 100644 index 0000000..b85c3f6 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountListEgressKeys.rst @@ -0,0 +1,27 @@ +====================== +accountListEgressKeys +====================== + +List the public egress keys of an account +========================================= + + +.. admonition:: usage + :class: cmdusage + + --osh accountListEgressKeys --account ACCOUNT + +.. program:: accountListEgressKeys + + +.. option:: --account ACCOUNT + + Account to display the public egress keys of + + +The keys listed are the public egress SSH keys tied to this account. +They can be used to gain access to another machine from this bastion, +by putting one of those keys in the remote machine's ``authorized_keys`` file, +and adding this account access to this machine with ``accountAddPersonalAccess``. + + diff --git a/doc/sphinx/plugins/restricted/accountListIngressKeys.rst b/doc/sphinx/plugins/restricted/accountListIngressKeys.rst new file mode 100644 index 0000000..5c8c358 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountListIngressKeys.rst @@ -0,0 +1,26 @@ +======================= +accountListIngressKeys +======================= + +List the public ingress keys of an account +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountListIngressKeys --account ACCOUNT + +.. program:: accountListIngressKeys + + +.. option:: --account ACCOUNT + + Account to list the keys of + + +The keys listed are the public ingress SSH keys tied to this account. +Their private counterpart should be detained only by this account's user, +so that they can to authenticate themselves to this bastion. + + diff --git a/doc/sphinx/plugins/restricted/accountListPasswords.rst b/doc/sphinx/plugins/restricted/accountListPasswords.rst new file mode 100644 index 0000000..711a13f --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountListPasswords.rst @@ -0,0 +1,24 @@ +===================== +accountListPasswords +===================== + +List the hashes and metadata of the egress passwords associated to an account +============================================================================= + + +.. admonition:: usage + :class: cmdusage + + --osh accountListPasswords --account ACCOUNT + +.. program:: accountListPasswords + + +.. option:: --account ACCOUNT + + The account name to work on + + +The passwords corresponding to these hashes are only needed for devices that don't support key-based SSH + + diff --git a/doc/sphinx/plugins/restricted/accountMFAResetPassword.rst b/doc/sphinx/plugins/restricted/accountMFAResetPassword.rst new file mode 100644 index 0000000..e2677e7 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountMFAResetPassword.rst @@ -0,0 +1,24 @@ +======================== +accountMFAResetPassword +======================== + +Remove the UNIX password of an account +====================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountMFAResetPassword --account ACCOUNT + +.. program:: accountMFAResetPassword + + +.. option:: --account ACCOUNT + + Specify which account you want to remove the UNIX password of + + +Note that if doesn't remove the account UNIX password requirement, if set (see ``accountModify`` for this) + + diff --git a/doc/sphinx/plugins/restricted/accountMFAResetTOTP.rst b/doc/sphinx/plugins/restricted/accountMFAResetTOTP.rst new file mode 100644 index 0000000..f2d75e1 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountMFAResetTOTP.rst @@ -0,0 +1,24 @@ +==================== +accountMFAResetTOTP +==================== + +Remove the TOTP configuration of an account +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountMFAResetTOTP --account ACCOUNT + +.. program:: accountMFAResetTOTP + + +.. option:: --account ACCOUNT + + Specify which account you want to remove the TOTP configuration of + + +Note that if doesn't remove the TOTP requirement, if set (see ``accountModify`` for this). + + diff --git a/doc/sphinx/plugins/restricted/accountModify.rst b/doc/sphinx/plugins/restricted/accountModify.rst new file mode 100644 index 0000000..c15d8f8 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountModify.rst @@ -0,0 +1,58 @@ +============== +accountModify +============== + +Modify an account configuration +=============================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountModify --account ACCOUNT [--option value [--option value [...]]] + +.. program:: accountModify + + +.. option:: --account ACCOUNT + + Bastion account to work on + +.. option:: --pam-auth-bypass yes|no + + Enable or disable PAM auth bypass for this account in addition to pubkey auth (default is 'no'), + + in that case sshd will not rely at all on PAM auth and /etc/pam.d/sshd configuration. This + does not change the behaviour of the code, just the PAM auth handled by SSH itself +.. option:: --mfa-password-required yes|no|bypass + + Enable or disable UNIX password requirement for this account in addition to pubkey auth (default is 'no'), + + this overrides the global bastion configuration 'accountMFAPolicy'. If 'bypass' is specified, + no password will ever be asked, even for groups or plugins explicitly requiring it +.. option:: --mfa-totp-required yes|no|bypass + + Enable or disable TOTP requirement for this account in addition to pubkey auth (default is 'no'), + + this overrides the global bastion configuration 'accountMFAPolicy'. If 'bypass' is specified, + no OTP will ever be asked, even for groups or plugins explicitly requiring it +.. option:: --egress-strict-host-key-checking POLICY + + Modify the egress SSH behavior of this account regarding StrictHostKeyChecking (see man ssh_config), + + POLICY can be 'yes', 'no', 'ask', 'default' or 'bypass' +.. option:: --personal-egress-mfa-required POLICY + + Enforce UNIX password requirement, or TOTP requirement, or any MFA requirement, when connecting to a server + + using the personal keys of the account, POLICY can be 'password', 'totp', 'any' or 'none' +.. option:: --always-active yes|no + + Set or unset the account as always active (i.e. disable the check of the 'active' status on this account) + +.. option:: --idle-ignore yes|no + + If enabled, this account is immune to the idleLockTimeout and idleKillTimeout bastion-wide policy + + + diff --git a/doc/sphinx/plugins/restricted/accountPIV.rst b/doc/sphinx/plugins/restricted/accountPIV.rst new file mode 100644 index 0000000..310647a --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountPIV.rst @@ -0,0 +1,37 @@ +=========== +accountPIV +=========== + +Modify the PIV policy for the ingress keys of an account +======================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountPIV --account ACCOUNT --policy + +.. program:: accountPIV + + +Options: +.. option:: --account ACCOUNT + + Bastion account to work on + +.. option:: --policy none|enforce|grace + + Changes the PIV policy of account. 'none' disables the PIV enforcement, any SSH key can be used + + as long as it respects the bastion policy. 'enforce' enables the PIV enforcement, only PIV keys + can be added as ingress SSH keys. 'grace' enables temporary deactivation of PIV enforcement on + an account, only meaningful when policy is already set to 'enforce' for this account, 'grace' + requires the use of the --ttl option to specify how much time the policy will be relaxed for this + account before going back to 'enforce' automatically. +.. option:: --ttl SECONDS|DURATION + + For the 'grace' policy, amount of time after which the account will automatically go back to 'enforce' + + policy (amount of seconds, or duration string such as "4d12h15m") + + diff --git a/doc/sphinx/plugins/restricted/accountRevokeCommand.rst b/doc/sphinx/plugins/restricted/accountRevokeCommand.rst new file mode 100644 index 0000000..49d3897 --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountRevokeCommand.rst @@ -0,0 +1,26 @@ +===================== +accountRevokeCommand +===================== + +Revoke access to a restricted command +===================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountRevokeCommand --account ACCOUNT --command COMMAND + +.. program:: accountRevokeCommand + + +.. option:: --account ACCOUNT + + Bastion account to work on + +.. option:: --command COMMAND + + The name of the OSH plugin to revoke access to (omit to get the list) + + + diff --git a/doc/sphinx/plugins/restricted/accountUnexpire.rst b/doc/sphinx/plugins/restricted/accountUnexpire.rst new file mode 100644 index 0000000..bcbaf4d --- /dev/null +++ b/doc/sphinx/plugins/restricted/accountUnexpire.rst @@ -0,0 +1,25 @@ +================ +accountUnexpire +================ + +Unexpire an inactivity-expired account +====================================== + + +.. admonition:: usage + :class: cmdusage + + --osh accountUnexpire --account ACCOUNT + +.. program:: accountUnexpire + + +.. option:: --account ACCOUNT + + Account to work on + + +When the bastion is configued to expire accounts that haven't been seen in a while, +this command can be used to activate them back. + + diff --git a/doc/sphinx/plugins/restricted/groupCreate.rst b/doc/sphinx/plugins/restricted/groupCreate.rst new file mode 100644 index 0000000..b975619 --- /dev/null +++ b/doc/sphinx/plugins/restricted/groupCreate.rst @@ -0,0 +1,59 @@ +============ +groupCreate +============ + +Create a new bastion group +========================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupCreate --group NAME --owner ACCOUNT --algo ALGO --size SIZE [OPTIONS] + +.. program:: groupCreate + + +.. option:: --group NAME + + Group name to create, NAME must contain only valid UNIX group name characters + +.. option:: --owner ACCOUNT + + Account to set as the group owner, this account will have complete rights to manage the group + +.. option:: --algo ALGO + + Specifies the algo of the key, usually either rsa, ecdsa or ed25519. Note that the available algorithms depend on the OS the bastion is running on, along with its configuration policies + +.. option:: --size SIZE + + Specifies the size of the key to be generated. + For RSA, choose between 2048 and 8192 (any value above 4096 is probably not very useful). + For ECDSA, choose either 256, 384 or 521. + For ED25519, size is always 256. + +.. option:: --encrypted + + When specified, a passphrase will be prompted for the new key, and the private key will be stored encrypted on the bastion. Note that the passphrase will be required each time you want to use the key. + +.. option:: --no-key + + No egress keypair will be generated. In that case, omit ``--algo`` and ``--size``. + +Algorithms guideline +==================== + +A quick overview of the different algorithms:: + + +---------+------+-----------+---------+-----------------------------------------+ + | algo | size | strength | speed | compatibility | + +=========+======+===========+=========+=========================================+ + | DSA | any | 0 | n/a | obsolete, do not use | + | RSA | 2048 | ** | ** | works everywhere | + | RSA | 4096 | *** | * | works almost everywhere | + | ECDSA | 521 | **** | ***** | OpenSSH 5.7+ (debian 7+, ubuntu 12.04+) | + | ED25519 | 256 | ***** | ***** | OpenSSH 6.5+ (debian 8+, ubuntu 14.04+) | + +---------+------+-----------+---------+-----------------------------------------+ + +This table is meant as a quick cheat-sheet, you're warmly advised to do your own research, as other constraints may apply to your environment. diff --git a/doc/sphinx/plugins/restricted/groupDelete.rst b/doc/sphinx/plugins/restricted/groupDelete.rst new file mode 100644 index 0000000..93fd962 --- /dev/null +++ b/doc/sphinx/plugins/restricted/groupDelete.rst @@ -0,0 +1,26 @@ +============ +groupDelete +============ + +Delete a group +============== + + +.. admonition:: usage + :class: cmdusage + + --osh groupDelete --group GROUP + +.. program:: groupDelete + + +.. option:: --group GROUP + + Group name to delete + +.. option:: --no-confirm + + Skip group name confirmation, but blame yourself if you deleted the wrong group! + + + diff --git a/doc/sphinx/plugins/restricted/index.rst b/doc/sphinx/plugins/restricted/index.rst new file mode 100644 index 0000000..7edc0d1 --- /dev/null +++ b/doc/sphinx/plugins/restricted/index.rst @@ -0,0 +1,34 @@ +=================== +restricted plugins +=================== + +.. toctree:: + + accountAddPersonalAccess + accountCreate + accountDelete + accountDelPersonalAccess + accountGeneratePassword + accountGrantCommand + accountInfo + accountListAccesses + accountListEgressKeys + accountListIngressKeys + accountListPasswords + accountList + accountMFAResetPassword + accountMFAResetTOTP + accountModify + accountPIV + accountRevokeCommand + accountUnexpire + groupCreate + groupDelete + realmCreate + realmDelete + realmInfo + realmList + rootListIngressKeys + selfAddPersonalAccess + selfDelPersonalAccess + whoHasAccessTo diff --git a/doc/sphinx/plugins/restricted/realmCreate.rst b/doc/sphinx/plugins/restricted/realmCreate.rst new file mode 100644 index 0000000..79dcd6a --- /dev/null +++ b/doc/sphinx/plugins/restricted/realmCreate.rst @@ -0,0 +1,36 @@ +============ +realmCreate +============ + +Declare and create a new trusted realm +====================================== + + +.. admonition:: usage + :class: cmdusage + + --osh realmCreate --realm REALM [OPTIONS] + +.. program:: realmCreate + + +.. option:: --realm REALM + + Realm name to create + +.. option:: --comment STRING + + An optional comment when creating the realm. Double-quote if you're under a shell. + +.. option:: --from + + IP1,IP2 Comma-separated list of outgoing IPs used by the realm we're declaring (i.e. IPs used by the bastion(s) on the other side) + + the expected format is the one used by the from="" directive on SSH keys (IP and prefixes are supported) +.. option:: --public-key KEY + + Public SSH key to deposit on the bastion to access this realm. If not present, + + you'll be prompted interactively for it. Use double-quoting if your're under a shell. + + diff --git a/doc/sphinx/plugins/restricted/realmDelete.rst b/doc/sphinx/plugins/restricted/realmDelete.rst new file mode 100644 index 0000000..086fce9 --- /dev/null +++ b/doc/sphinx/plugins/restricted/realmDelete.rst @@ -0,0 +1,22 @@ +============ +realmDelete +============ + +Delete a bastion realm +====================== + + +.. admonition:: usage + :class: cmdusage + + --osh realmDelete --realm REALM + +.. program:: realmDelete + + +.. option:: --realm REALM + + Name of the realm to delete + + + diff --git a/doc/sphinx/plugins/restricted/realmInfo.rst b/doc/sphinx/plugins/restricted/realmInfo.rst new file mode 100644 index 0000000..876b263 --- /dev/null +++ b/doc/sphinx/plugins/restricted/realmInfo.rst @@ -0,0 +1,22 @@ +========== +realmInfo +========== + +Display informations about a bastion realm +========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh realmInfo --realm REALM + +.. program:: realmInfo + + +.. option:: --realm REALM + + Name of the realm to show info about + + + diff --git a/doc/sphinx/plugins/restricted/realmList.rst b/doc/sphinx/plugins/restricted/realmList.rst new file mode 100644 index 0000000..c79c707 --- /dev/null +++ b/doc/sphinx/plugins/restricted/realmList.rst @@ -0,0 +1,22 @@ +========== +realmList +========== + +List the bastions realms +======================== + + +.. admonition:: usage + :class: cmdusage + + --osh realmList [--realm REALM] + +.. program:: realmList + + +.. option:: --realm REALM + + Only list the specified realm (mainly: check if it exists) + + + diff --git a/doc/sphinx/plugins/restricted/rootListIngressKeys.rst b/doc/sphinx/plugins/restricted/rootListIngressKeys.rst new file mode 100644 index 0000000..be1e799 --- /dev/null +++ b/doc/sphinx/plugins/restricted/rootListIngressKeys.rst @@ -0,0 +1,21 @@ +==================== +rootListIngressKeys +==================== + +List the public keys to connect as root on this bastion +======================================================= + + +.. admonition:: usage + :class: cmdusage + + --osh rootListIngressKeys + +.. program:: rootListIngressKeys + + +This command is mainly useful for auditability purposes. +As it gives some information as to who can be root on the underlying system, +please grant this command only to accounts that need to have this information. + + diff --git a/doc/sphinx/plugins/restricted/selfAddPersonalAccess.rst b/doc/sphinx/plugins/restricted/selfAddPersonalAccess.rst new file mode 100644 index 0000000..69e46c1 --- /dev/null +++ b/doc/sphinx/plugins/restricted/selfAddPersonalAccess.rst @@ -0,0 +1,62 @@ +====================== +selfAddPersonalAccess +====================== + +Remove a personal server access from an account +=============================================== + + +.. admonition:: usage + :class: cmdusage + + --osh selfAddPersonalAccess --host HOST [OPTIONS] + +.. program:: selfAddPersonalAccess + + +.. option:: --host IP|HOST|IP/MASK + + Server to add access to + +.. option:: --user USER + + Remote login to use, if you want to allow any login, use --user-any + +.. option:: --user-any + + Allow access with any remote login + +.. option:: --port PORT + + Remote SSH port to use, if you want to allow any port, use --port-any + +.. option:: --port-any + + Allow access to all remote ports + +.. option:: --scpup + + Allow SCP upload, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Allow SCP download, you<--bastion--server (omit --user in this case) + +.. option:: --force + + Add the access without checking that the public SSH key is properly installed remotely + +.. option:: --force-key FINGERPRINT + + Only use the key with the specified fingerprint to connect to the server (cf selfListEgressKeys) + +.. option:: --ttl SECONDS|DURATION + + Specify a number of seconds (or a duration string, such as "1d7h8m") after which the access will automatically expire + +.. option:: --comment "'ANY TEXT'" + + Add a comment alongside this server. Quote it twice as shown if you're under a shell. + + + diff --git a/doc/sphinx/plugins/restricted/selfDelPersonalAccess.rst b/doc/sphinx/plugins/restricted/selfDelPersonalAccess.rst new file mode 100644 index 0000000..dfa73f5 --- /dev/null +++ b/doc/sphinx/plugins/restricted/selfDelPersonalAccess.rst @@ -0,0 +1,46 @@ +====================== +selfDelPersonalAccess +====================== + +Remove a personal server access from your account +================================================= + + +.. admonition:: usage + :class: cmdusage + + --osh selfDelPersonalAccess --host HOST [OPTIONS] + +.. program:: selfDelPersonalAccess + + +.. option:: --host IP|HOST|IP/MASK + + Server to remove access from + +.. option:: --user USER + + Remote user that was allowed, if any user was allowed, use --user-any + +.. option:: --user-any + + Use if any remote login was allowed + +.. option:: --port PORT + + Remote SSH port that was allowed, if any port was allowed, use --port-any + +.. option:: --port-any + + Use if any remote port was allowed + +.. option:: --scpup + + Remove SCP upload right, you--bastion-->server (omit --user in this case) + +.. option:: --scpdown + + Remove SCP download right, you<--bastion--server (omit --user in this case) + + + diff --git a/doc/sphinx/plugins/restricted/whoHasAccessTo.rst b/doc/sphinx/plugins/restricted/whoHasAccessTo.rst new file mode 100644 index 0000000..1a14fed --- /dev/null +++ b/doc/sphinx/plugins/restricted/whoHasAccessTo.rst @@ -0,0 +1,49 @@ +=============== +whoHasAccessTo +=============== + +List the accounts that have access to a given server +==================================================== + + +.. admonition:: usage + :class: cmdusage + + --osh whoHasAccessTo --host SERVER [OPTIONS] + +.. program:: whoHasAccessTo + + +.. option:: --host SERVER + + List declared accesses to this server + +.. option:: --user USER + + Remote user allowed (if not specified, ignore user specifications) + +.. option:: --port PORT + + Remote port allowed (if not specified, ignore port specifications) + +.. option:: --ignore-personal + + Don't check accounts' personal accesses (i.e. only check groups) + +.. option:: --ignore-group GROUP + + Ignore accesses by this group, if you know GROUP public key is in fact + + not present on remote server but bastion thinks it is +.. option:: --show-wildcards + + Also list accesses that match because 0.0.0.0/0 is listed in a group or private access, + + this is disabled by default because this is almost always just noise (see Note below) + +Note: This list is what the bastion THINKS is true, which means that if some group has 0.0.0.0/0 in its list, +then it'll show all the members of that group as having access to the machine you're specifying, through this group key. +This is only true if the remote server does have the group key installed, of course, which the bastion +can't tell without trying to connect "right now" (which it won't do). + + diff --git a/doc/sphinx/presentation/features.rst b/doc/sphinx/presentation/features.rst new file mode 100644 index 0000000..5784f9c --- /dev/null +++ b/doc/sphinx/presentation/features.rst @@ -0,0 +1,6 @@ +======== +Features +======== + +- Supports MOSH on the ingress connection side +- Supports ``scp`` passthrough, to upload and/or download files diff --git a/doc/sphinx/presentation/index.rst b/doc/sphinx/presentation/index.rst new file mode 100644 index 0000000..532ace4 --- /dev/null +++ b/doc/sphinx/presentation/index.rst @@ -0,0 +1,9 @@ +============ +Presentation +============ + +.. toctree:: + + principles + features + security diff --git a/doc/sphinx/presentation/principles.rst b/doc/sphinx/presentation/principles.rst new file mode 100644 index 0000000..0ee5529 --- /dev/null +++ b/doc/sphinx/presentation/principles.rst @@ -0,0 +1,3 @@ +========== +Principles +========== diff --git a/doc/sphinx/presentation/security.rst b/doc/sphinx/presentation/security.rst new file mode 100644 index 0000000..f906524 --- /dev/null +++ b/doc/sphinx/presentation/security.rst @@ -0,0 +1,52 @@ +======== +Security +======== + +Security principles at the core +=============================== + +Even with the most conservative, precautionous and paranoid coding process, code has bugs, so it shouldn't be trusted blindly. Hence the bastion doesn't trust its own code. It leverages the operating system security primitives to get additional security, as seen below. + +- Uses the well-known and trusted UNIX Discretionary Access Control: + + - Bastion users are mapped to actual system users + - Bastion groups are mapped to actual system groups + - All the code is constantly checking rights before allowing any action + - UNIX DAC is used as a safety belt to prevent an action from succeeding even if the code is tricked into allowing it + +- The bastion main script is declared as the bastion user's system shell: + + - No user has real (``bash``-like) shell access on the system + - All code is ran under the unprivileged user's system account rights + - Even if a user could escape to a real shell, he wouldn't be able to connect to machines he doesn't have access to, because he doesn't have filesystem-level read access to the SSH keys + +- The code is modular + + - The main code mainly checks rights, logs actions, and enable ``ssh`` access to other machines + - All side commands, called **plugins**, are in modules separated from the main code + - The modules can either be **open** or **restricted** + + - Only accounts that have been specifically granted on a need-to-use basis can run a specific restricted plugin + - This is checked by the code, and also enforced by UNIX DAC (the plugin is only readable and executable by the system group specific to the plugin) + +- All the code needing extended system privileges is separated from the main code, in modules called **helpers** + + - Helpers are run exclusively under ``sudo`` + - The ``sudoers`` configuration is attached to a system group specific to the command, which is granted to accounts on a need-to-use basis + - The helpers are only readable and executable by the system group specific to the command + - The helpers path and some of their immutable parameters are hardcoded in the ``sudoers`` configuration + - Perl tainted mode (``-T``) is used for all code running under ``sudo``, preventing any user-input to interfere with the logic, by halting execution immediately + - Code running under ``sudo`` doesn't trust its caller and re-checks every input + - Communication between unprivileged and privileged-code are done using JSON + +Auditability +============ + +- Bastion administrators must use the bastion's logic to connect to itself to administer it (or better, use another bastion to do so), this ensures auditability in all cases + +- Every access and action (whether allowed or denied) is logged with: + + - ``syslog``, which should also be sent to a remote syslog server to ensure even bastion administrators can't tamper their tracks, and/or + - local ``sqlite3`` databases for easy searching + +- This code is used in production in several PCI-DSS, ISO 27001, SOC1 and SOC2 certified environments diff --git a/doc/sphinx/using/basics.rst b/doc/sphinx/using/basics.rst new file mode 100644 index 0000000..d749099 --- /dev/null +++ b/doc/sphinx/using/basics.rst @@ -0,0 +1,251 @@ +========== +The basics +========== + +We make the assumption here that you already have a bastion account: + +- either you're one of the admins who just :doc:`installed<../installation/basic>` it, or +- one of the admins created an account for you + +If you are an admin and want to create accounts for your users, this is explained :doc:`here`. + +First steps +=========== + +Bastion alias +************* + +You should setup a *bastion alias* to make it easy to connect to the bastion. An example of the proper alias to use for your account is given to the bastion administrator when s/he creates your account, and is usually something along the lines of: + +.. code-block:: shell + + alias bssh='ssh -t myname@the-bastion.example.org --' + +Of course, you can modify it as you see fit, for example adding the ``-i`` argument to specify the private SSH key to use to connect to the bastion. You can use any name as the alias, but it's advised to keep it short, as you'll use it quite often. + +For the remaining of this documentation, we'll assume your bastion alias is `bssh`. + +You can do to categories of things on the bastion: + +- Connect to infrastructures throught it +- Interact with the bastion itself, for example to manage your account, and/or groups, through so-called *plugins*, also named *osh commands* + +Plugins +******* + +We'll start by using the ``info`` plugin, to verify that your bastion access works correctly: + +.. code-block:: shell + :emphasize-lines: 1 + + $ bssh --osh info + *------------------------------------------------------------------------------* + |THIS IS A PRIVATE COMPUTER SYSTEM, UNAUTHORIZED ACCESS IS STRICTLY PROHIBITED.| + |ALL CONNECTIONS ARE LOGGED. IF YOU ARE NOT AUTHORIZED, DISCONNECT NOW. | + *------------------------------------------------------------------------------* + Enter PIN for 'PIV Card Holder pin (PIV_II)': + ---the-bastion.example.org----------------------------the-bastion-2.99.99-rc9--- + => information + -------------------------------------------------------------------------------- + ~ You are johndoe + ~ You are a bastion auditor! + ~ Look at you, you are a bastion superowner! + ~ Woosh, you are even a bastion admin! + ~ + ~ Your alias to connect to this bastion is: + ~ alias bssh='ssh johndoe@the-bastion.example.org -p 22 -t -- ' + ~ Your alias to connect to this bastion with MOSH is: + ~ alias bsshm='mosh --ssh="ssh -p 22 -t" johndoe@the-bastion.example.org -- ' + ~ + ~ [...] + ~ + ~ Here is your excuse for anything not working today: + ~ BOFH excuse #46: + ~ waste water tank overflowed onto computer + ------------------------------------------------------------------------- + Connection to the-bastion.example.org closed. + +Congratulations, you've just used your first command on the bastion! + +You can get a list of all the plugins you can use by saying: + +.. code-block:: shell + + $ bssh --osh help + +The list will depend on your access level on the bastion, as some commands are restricted. You can have more information about any command by using ``--help`` with it: + +.. code-block:: shell + + $ bssh --osh selfAddIngressKey --help + +See :doc:`here ` for more information about the plugins. + +Instead of using ``--osh`` to call plugins, you can enter the special *interactive mode*, by saying: + +.. code-block:: shell + + $ bssh -i + +In this mode, you can directly enter commands, and also use auto-completion features with the ```` key. You can start by just typing ``help``, which is the equivalent of saying ``bssh --osh help``. For security reasons, the interactive mode will disconnect you after a given amount of idle-time. + +Setting up access to a server +***************************** + +This section assumes that you have a server you want to secure access to, using the bastion. We'll call it *server42.example.org*, with IP 198.51.100.42. To do this, we'll use the **selfAddAccess** command. + +Let's use the interactive mode to get the auto-completion features: + +.. code-block:: shell + :emphasize-lines: 1 + + $ bssh -i + Enter PIN for 'PIV Card Holder pin (PIV_II)': + + Welcome to bssh interactive mode, type `help' for available commands. + You can use and for autocompletion. + You'll be disconnected after 60 seconds of inactivity. + Loading... 88 commands and 341 autocompletion rules loaded. + + bssh(master)> + +You can enter the first few characters of the command, then use ```` to help you complete it, then use ```` again to show you the required arguments. The complete command would be as follows: + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfAddPersonalAccess --host 198.51.100.42 --port 22 --user root + ---the-bastion.example.org----------------------------the-bastion-2.99.99-rc9--- + => adding private access to a server on your account + -------------------------------------------------------------------------------- + ~ Testing connection to root@198.51.100.42, please wait... + Warning: Permanently added '198.51.100.42' (ECDSA) to the list of known hosts. + root@198.51.100.42: Permission denied (publickey). + ~ Note: if you still want to add this access even if it doesn't work, use --force + ~ Couldn't connect to root@198.51.100.42 (ssh returned error 255). Hint: did you add the proper public key to the remote's authorized_keys? + -------------------------------------------------------- + bssh(master)> + +You'll notice that it didn't work. This is because first, you need to add your *personal egress key* to the remote machine's *authorized_keys* file. If this seems strange, here is :doc:`how it works <../presentation/principles>`. To get your *personal egress key*, you can use this command: + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfListEgressKeys + ---the-bastion.example.org----------------------------the-bastion-2.99.99-rc9--- + => the public part of your personal bastion key + -------------------------------------------------------------------------------- + ~ You can copy one of those keys to a remote machine to get access to it through your account + ~ on this bastion, if it is listed in your private access list (check selfListAccesses) + ~ + ~ Always include the from="198.51.100.1/32" part when copying the key to a server! + ~ + ~ fingerprint: SHA256:rMpoCaYPSfRqmOBFOJvEr5uLqxYjqYtRDgUoqUwH2nA (ED25519-256) [2019/07/11] + ~ keyline follows, please copy the *whole* line: + from="198.51.100.1/32" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILnY2NQTKsTDxgcaTE6vHVm9FIbud1rJcYQ/4xUyr+DK johndoe@bssh:1562861572 + ----------------------------------------------------------- + +Now that you have it, you can push this public key (the line starting with the *from=*) to the remote server's root authorized_keys, i.e. ``/root/.ssh/authorized_keys``. Now, you can add your access properly: + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfAddPersonalAccess --host 198.51.100.42 --port 22 --user root + ---the-bastion.example.org----------------------------the-bastion-2.99.99-rc9--- + => adding private access to a server on your account + -------------------------------------------------------------------------------- + ~ Testing connection to root@198.51.100.42, please wait... + Warning: Permanently added '198.51.100.42' (ECDSA) to the list of known hosts. + ~ Access to root@198.51.100.42:22 succesfully added + -------------------------------------------------------- + bssh(master)> + +All seems in order! Can we see this access we just created? + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfListAccesses + ---the-bastion.example.org----------------------------the-bastion-2.99.99-rc9--- + => your access list + -------------------------------------------------------------------------------- + ~ Dear johndoe, you have access to the following servers: + ~ IP PORT USER ACCESS-BY ADDED-BY ADDED-AT + ~ 198.51.100.42 22 root personal johndoe 2020-05-01 + -------------------------------------------------------- + bssh(master)> + +Connecting to a server and reviewing the session +************************************************ + +Good! Let's try to connect now! + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> ssh root@198.51.100.42 + ~ Welcome to the-bastion, johndoe, your last login was 00:13:37 ago (Fri 2020-08-28 13:07:43 UTC) from 192.0.2.11(proxy-11.example.org) + + proxy-11.example.org:40610 => johndoe@the-bastion.example.org:22 => root@server42.example.org:22 ... + allowed ... log on(/home/johndoe/ttyrec/198.51.100.42/2020-08-28.13-07-45.497020.fb00e1957b22.johndoe.root.198.51.100.42.22.ttyrec) + + will try the following accesses you have: + - personal access with ED25519-256 key SHA256:rMpoCaYPSfRqmOBFOJvEr5uLqxYjqYtRDgUoqUwH2nA [2019/07/11] + + Connecting... + + root@server42:~# id + uid=0(root) gid=0(root) groups=0(root),2(bin) + root@server42:~# + +We're now connected to server42, and can do our work as usual. Note that to connect to server42, one can directly use: + +.. code-block:: shell + + $ bssh root@198.51.100.42 + +Where `bssh` is the bastion alias we've just set up above, no need to enter interactive mode first of course. + +When we've done with server42, let's see if everything was correctly recorded: + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfListSessions --type ssh --detailed + ---bst-dev-a.bastions.ovh.net------------------the-bastion-2.99.99-rc9.2-ovh1--- + => your past sessions list + -------------------------------------------------------------------------------- + ~ The list of your 100 past sessions follows: + ~ + f4cca44a848e [2020/08/26@09:28:57 - 2020/08/26@09:29:57 ( 60.0)] type ssh from 192.0.2.11:33450(proxy-11.example.org) via johndoe@198.51.100.1:22 to root@198.51.100.42:22(server42.example.org) returned 0 + ------------------------------------------------------------- + +The first column is the unique identifier of the connection (or osh command). +Let's see what we did exactly during this session: + + +.. code-block:: shell + :emphasize-lines: 1 + + bssh(master)> selfPlaySession --id f4cca44a848e + ---bst-dev-a.bastions.ovh.net------------------the-bastion-2.99.99-rc9.2-ovh1--- + => replay a past session + -------------------------------------------------------------------------------- + ~ ID: f4cca44a848e + ~ Started: 2020/08/26 09:28:57 + ~ Ended: 2020/08/26 09:29:57 + ~ Duration: 0d+00:01:00.382820 + ~ Type: ssh + ~ From: 192.0.2.11:33450 (proxy-11.example.org) + ~ Via: johndoe@198.51.100.1:22 + ~ To: root@198.51.100.42:22 (server42.example.org) + ~ RetCode: 0 + ~ + ~ Press '+' to play faster + ~ Press '-' to play slower + ~ Press '1' to restore normal playing speed + ~ + ~ When you're ready to replay session 9f352fd4b85c, press ENTER. + ~ Starting from the next line, the Total Recall begins. Press CTRL+C to jolt awake. + +Now that you've connected to your first server, using a personal access, you may want to check out the groups access management, or directly dive into the Bastion plugins. diff --git a/doc/sphinx/using/index.rst b/doc/sphinx/using/index.rst new file mode 100644 index 0000000..4b02e32 --- /dev/null +++ b/doc/sphinx/using/index.rst @@ -0,0 +1,8 @@ +================= +Using the bastion +================= + +.. toctree:: + + basics + groups diff --git a/docker/Dockerfile.centos7 b/docker/Dockerfile.centos7 new file mode 100644 index 0000000..f8b19ee --- /dev/null +++ b/docker/Dockerfile.centos7 @@ -0,0 +1,29 @@ +FROM centos:7 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.centos8 b/docker/Dockerfile.centos8 new file mode 100644 index 0000000..89cfb2d --- /dev/null +++ b/docker/Dockerfile.centos8 @@ -0,0 +1,29 @@ +FROM centos:8 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.debian10 b/docker/Dockerfile.debian10 new file mode 100644 index 0000000..35e08d3 --- /dev/null +++ b/docker/Dockerfile.debian10 @@ -0,0 +1,32 @@ +FROM debian:buster +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.debian8 b/docker/Dockerfile.debian8 new file mode 100644 index 0000000..a683f06 --- /dev/null +++ b/docker/Dockerfile.debian8 @@ -0,0 +1,32 @@ +FROM debian:jessie +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.debian9 b/docker/Dockerfile.debian9 new file mode 100644 index 0000000..33485c6 --- /dev/null +++ b/docker/Dockerfile.debian9 @@ -0,0 +1,32 @@ +FROM debian:stretch +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.opensuse15 b/docker/Dockerfile.opensuse15 new file mode 100644 index 0000000..fdebde0 --- /dev/null +++ b/docker/Dockerfile.opensuse15 @@ -0,0 +1,29 @@ +FROM opensuse/leap:15.0 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=0 HAS_PAMTESTER=0 diff --git a/docker/Dockerfile.opensuse151 b/docker/Dockerfile.opensuse151 new file mode 100644 index 0000000..892c00d --- /dev/null +++ b/docker/Dockerfile.opensuse151 @@ -0,0 +1,29 @@ +FROM opensuse/leap:15.1 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=0 HAS_PAMTESTER=0 diff --git a/docker/Dockerfile.tester b/docker/Dockerfile.tester new file mode 100644 index 0000000..bfd27de --- /dev/null +++ b/docker/Dockerfile.tester @@ -0,0 +1,11 @@ +FROM debian:buster +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# install prerequisites +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y jq netcat openssh-client procps bsdutils screen expect shellcheck libperl-critic-perl fping + +# add our code +COPY . /opt/bastion + +# start at entrypoint +ENTRYPOINT /opt/bastion/tests/functional/docker/tester_role.sh diff --git a/docker/Dockerfile.ubuntu1404 b/docker/Dockerfile.ubuntu1404 new file mode 100644 index 0000000..08adbfb --- /dev/null +++ b/docker/Dockerfile.ubuntu1404 @@ -0,0 +1,32 @@ +FROM ubuntu:14.04 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=0 HAS_PAMTESTER=0 diff --git a/docker/Dockerfile.ubuntu1604 b/docker/Dockerfile.ubuntu1604 new file mode 100644 index 0000000..12d1c45 --- /dev/null +++ b/docker/Dockerfile.ubuntu1604 @@ -0,0 +1,32 @@ +FROM ubuntu:16.04 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.ubuntu1804 b/docker/Dockerfile.ubuntu1804 new file mode 100644 index 0000000..f8f0507 --- /dev/null +++ b/docker/Dockerfile.ubuntu1804 @@ -0,0 +1,32 @@ +FROM ubuntu:18.04 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/Dockerfile.ubuntu2004 b/docker/Dockerfile.ubuntu2004 new file mode 100644 index 0000000..b168c3a --- /dev/null +++ b/docker/Dockerfile.ubuntu2004 @@ -0,0 +1,32 @@ +FROM ubuntu:20.04 +LABEL maintainer="stephane.lesimple+bastion@ovhcloud.com" + +# cache builds efficiently: just copy the needed script to build ttyrec first +COPY bin/admin/build-and-install-ttyrec.sh /opt/bastion/bin/admin/ +COPY lib/shell /opt/bastion/lib/shell/ +RUN ["/opt/bastion/bin/admin/build-and-install-ttyrec.sh"] + +# then just some more bits to install the packages +COPY bin/admin/packages-check.sh /opt/bastion/bin/admin/ +RUN ["/opt/bastion/bin/admin/packages-check.sh","-i","-d","-s"] + +# handle locales +RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen + +# disable /dev/kmsg handling by syslog-ng and explicitely enable /dev/log +RUN test -e /etc/syslog-ng/syslog-ng.conf && \ + sed -i -re 's=system\(\);=unix-stream("/dev/log");=' /etc/syslog-ng/syslog-ng.conf + +# at each modification of our code, we'll start from here thanks to build cache +COPY . /opt/bastion + +# tests that the environment works +RUN ["/opt/bastion/bin/dev/perl-check.sh"] + +# setup ssh/sshd config and setup bastion install +RUN ["/opt/bastion/bin/admin/install","--new-install","--no-wait"] + +# start at entrypoint +ENTRYPOINT /opt/bastion/docker/entrypoint.sh + +# TESTENV HAS_ED25519=1 HAS_BLACKLIST=0 HAS_MFA=1 HAS_PAMTESTER=1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..01e4d04 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,13 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +if [ "$(uname -s)" = Linux ] ; then + test -x /etc/init.d/ssh && /etc/init.d/ssh start + test -x /etc/init.d/syslog-ng && /etc/init.d/syslog-ng start +else + # for BSD + test -x /etc/rc.d/sshd && /etc/rc.d/sshd onestart +fi + +while : ; do + sleep 3600 +done diff --git a/etc/bastion/bastion.conf.dist b/etc/bastion/bastion.conf.dist new file mode 100644 index 0000000..4884bfb --- /dev/null +++ b/etc/bastion/bastion.conf.dist @@ -0,0 +1,365 @@ +############################################################################################ +# Main config for The Bastion. +# This is a JSON file, its syntax must be valid at all times. To verify: +# => grep -v ^# /etc/bastion/bastion.conf|python -mjson.tool>/dev/null && echo OK +# +# If you're on a production bastion you can verify it can properly load its configuration: +# => perl -I/opt/bastion/lib/perl -MOVH::Bastion -e 'die OVH::Bastion::load_configuration()' +############################################################################################ + +{ +# +# bastionName (string), deprecated alias: cacheName +# DESC: this will be the name advertised in the aliases admins will give to bastion users, you can see it as a friendly name everybody will use to refer to this machine (try to avoid using the full hostname here) +# DEFAULT: fix-my-config-please-missing-bastion-name +"bastionName": "fix-my-config-please-missing-bastion-name", +# +# bastionCommand (string), deprecated alias: cacheCommand +# DESC: the ssh command to launch to connect to this bastion as a user. This will be printed on accountCreate, so that the new user knows how to connect. Magic values are ACCOUNT (replaced at runtime by the account name), BASTIONNAME (replaced at runtime by the name defined in `bastionName'), HOSTNAME (replaced at runtime by the hostname of the system, namely what is returned by `perl -MSys::Hostname -e 'print hostname'`). Note that previous magic values where USER (=ACCOUNT) and CACHENAME (=BASTIONNAME), they're still supported. +# DEFAULT: ssh USER@HOSTNAME -t -- +"bastionCommand": "ssh USER@HOSTNAME -t -- ", +# +# debug (boolean-int, i.e. 0 or 1) +# DESC: enables or disables debug GLOBALLY. Discouraged. Prefer using the dev/debug_toggle.sh tool to enable/disable per account. Mostly useful for bastion developer on bastion dev machine only. +# DEFAULT: 0 +"debug": 0, +# +# defaultLogin (string) +# DESC: The default remote user to use for egress ssh connections where no user has been specified by our caller. If set to "", will default to the account name of the caller. Some legacy bastions had "root" as a default, which is somehow discouraged but depends on your infrastructure policy. +# DEFAULT: "" +"defaultLogin": "", +# +# adminAccounts (list of accounts names), deprecated alias: adminLogins +# DESC: The list of accounts that are Admins of the bastion. Admins can't be deleted or have their ingress keys resetted by non-admins. They also gain access to special dangerous/sensitive --osh commands. Note that an admin is also always considered as a Super Owner, which means they can override allchecks of group administrative commands. Don't forget to add them to the osh-admin group too, or they won't really be considered as admins (additional security measure). Tule of thumb: only add here people that have root@localhost access to the bastion +# DEFAULT: [] +"adminAccounts": [], +# +# superOwnerAccounts (list of account names) +# VALUE: list of accounts that are considered as super group owners +# DESC: The list of accounts that are considered as "Super Group Owners". They can run all group administrative commands, exactly as if they were owners of all the groups. Super Owners are only here as a last resort when the owners/gatekeepers/aclkeepers of a group are not available. Every command run by a Super Owner that would have failed if the account was not a Super Owner is logged explicitely as "Super Owner Override". You can see it as a "sudo" for group management. Don't add here accounts that are bastion Admins, they already inherit the Super Owner role. +# DEFAULT: [] +"superOwnerAccounts": [], +# +# allowedNetworks (list of IPs and/or prefixes) +# DESC: Restricts egress connection attempts to those listed networks only. This is enforced at all times and can NOT be overridden by users. It's probably a good idea to list the prefixes of your ASN here. +# DEFAULT: empty, which means no restriction +"allowedNetworks": [], +# +# forbiddenNetworks (list of IPs and/or prefixes) +# DESC: Prevents egress connection to the listed networks, even if they match configured allowed networks. This can be used to prevent connection to some hosts or subnets in a broadly allowed prefix. This is enforced at all times and can NOT be overridden by users. +# DEFAULT: empty, which means no restriction +"forbiddenNetworks": [], +# +# ingressToEgressRules (array of arrays of rules, a rule being a 3-uple of {array, array, string}) +# DESC: Fine-grained rules (a la netfilter) to apply global restrictions to possible egress destinations given ingress IPs. Rules here are enforced at all times and can NOT be overriden by users or admins. +# DEFAULT: [], which means no restriction +# DETAILS: A rule is a 3-uple of {array of ingress networks, array of egress networks, policy to apply}. +# Each rule will be processed IN ORDER. The first rule to match will be applied and no other rule will be checked. +# If no rule matches, the default is to apply no restriction. +# The "policy to apply" item can have 3 values: +# - ALLOW, which means that if the ingress IP matches one of the ingress networks specified in the rule, and the egress IP matches one of the egress networks specified, no restriction will be applied (all rights-check of groups and personal accesses still apply) +# - DENY, which means that if the ingress IP matches one of the ingress networks specified in the rule, and the egress IP matches one of the egress networks specified, access will be denied even before checking any group or personal accesses +# - ALLOW-EXCLUSIVE, which means that if the ingress IP matches one of the ingress networks specified in the rule, but the egress IP DOES NOT match any of the egress network specified, access will be denied. Access will still be allowed if the egress IP matches one of the egress networks specified. This is an easy way to exclusively allow a list of egress networks given a list of ingress networks, and deny any other access otherwise, for those ingress networks. +# EXAMPLE: [ +# [["10.19.0.0/16","10.15.15.0/24"], ["10.20.0.0/16"], "ALLOW-EXCLUSIVE"], +# [["192.168.42.0/24"], ["192.168.42.0/24"], "ALLOW"], +# [["192.168.0.0/16"], ["192.168.0.0/16"], "DENY"] +# ] +# - The 10.19.0.0/16 and 10.15.15.0/24 networks can only access the 10.20.0.0/16 network (rule #1) +# - The 192.168.42.0/24 network can access any machine from its own /24 network (rule #2), +# but not any other machine from the wider 192.168.0.0/16 network (rule #3). It can however +# access any other machine outside of this block (implicit allow catch-all rule, as there is +# no corresponding DENY rule, and rule #2 is ALLOW and not ALLOW-EXCLUSIVE) +# - The 192.168.0.0/16 network (except 192.168.42.0/16) can accesss any machine except one from its own network (rule #3) +# - All the other networks can access any other network (including egress 10.20.0.0/16 or egress 192.168.0.0/16) +# In any case, all the personal and group accesses still apply in addition to these global rules +"ingressToEgressRules": [], +# +# egressKeysFrom (list of IPs and/or prefixes), deprecated alias: personalKeyFrom +# DESC: These IPs will be added to the from="..." of the personal account keys and the group keys. Typically you want to specify only the bastions IP here (including all the slaves). +# DEFAULT: if NOT set at all or set to the empty array, will default to autodetection at runtime (legacy behavior, discouraged) +"egressKeysFrom": [], +# +# ingressKeysFrom (list of IPs and/or prefixes), deprecated alias: ipWhiteList +# DESC: This array of IPs (or prefixes, such as 10.20.30.0/24) will be used to build the from="" in front of the ingress account public keys used to connect to the bastion (in accountCreate or selfAddIngressKey). If the array is empty, then NO from="" is added. +# DEFAULT: [] +"ingressKeysFrom": [], +# +# ingressKeysFromAllowOverride (boolean-int, i.e. 0 or 1), aliases: ipWhiteListAllowOverride (deprecated) +# DESC: If set to 0 (false), any from="..." specified in user keys (selfAddIngressKey or accountCreate) are ignored and replaced by the IPs in the ingressKeysFrom configuration option (if any). +# If set to 1 (true), any from="..." specified in user keys (selfAddIngressKey or accountCreate) will override the value set in ingressKeysFrom (if any). When no user-specified from="..." appears, the value of ingressKeysFrom is still used, regardless of this option. +# DEFAULT: 0 +"ingressKeysFromAllowOverride": 1, +# +# accountUidMin (int) +# DESC: minimum allowed UID for accounts on this bastion. Hardcoded > 1000 even if configured for less +# DEFAULT: 2000 +"accountUidMin": 2000, +# +# accountUidMax (int) +# DESC: maximum allowed UID for accounts on this bastion. +# DEFAULT: 99999 +"accountUidMax": 99999, +# +# ttyrecGroupIdOffset (int) +# DESC: offset to apply on user group uid to create -tty group, should be > accountUidMax +# DEFAULT: 100000 +"ttyrecGroupIdOffset": 100000, +# +# accountExternalValidationProgram (path to a binary) +# DESC: Binary or script that will be called by the bastion, with the account name in parameter, to check whether this account should be allowed to connect to the bastion. If empty, this check is skipped. $BASEDIR is a magic value that is replaced by where the bastion code lives (usually, /opt/bastion). You can use this configuration parameter to counter-verify all accounts against an external system, for example an LDAP, right when they're connecting to the bastion (on the ingress side). However, it is advised to avoid calling an external system in the flow of an incoming connection (this violates the "the bastion must be working at all times, regardless of the status of the other components of the company's infrastructure" rule). Instead, you should have a cronjob to periodically fetch all the allowed accounts from said external system, and store this list somewhere on the bastion, then write a simple script that will be called by the bastion to verify whether the connecting account is present on this locally cached list. An account present in this list, is called an "active account", in the bastion's jargon. An inactive account is an account existing on the bastion, but not in this list, and won't be able to connect. Note that for security reasons, inactive bastions administrators would be denied as any other account. The result is interpreted from the program's exit code. If the program return 0, the account is deemed active. If the program returns 1, the account is deemed inactive. A return code of 2, 3 or 4 indicates a failure of the program in determining the activeness of the account. In this case, the decision to allow or deny the access is determined by the option below. Status code 3 additionally logs the stderr of the program *silently* to the syslog (this can be used to warn admins of a problem without leaking information to the user). Status code 4 does the same, but the stderr is also shown directly to the user. Any other return code deems the account inactive (same behavior that return code 1). +# DEFAULT: "" +# EXAMPLE: "$BASEDIR/bin/other/check-active-account-simple.pl" +"accountExternalValidationProgram": "", +# +# accountExternalValidationDenyOnFailure (boolean-int, aka 0 or 1) +# DESC: If we can't validate an account using the above configured program, for example because the path doesn't exist, the file is not executable, or because the program returns the exit code 4 (see above for more informaton), this configuration option indicates whether we should deny or allow access. Note that the bastion admins will always be allowed if the accountExternalValidationProgram doesn't work correctly, because they're expected to be able to fix it. They would be denied, as any other account, if accountExternalValidationProgram works correctly and denies them access, however. If you're still testing your account validation procedure, and don't want to break your users workflow while you're not 100% sure it works correctly, you can say 0 ("false") here, and return 4 instead of 1 in your accountExternalValidationProgram when you would want to deny access. +# DEFAULT: 1 +"accountExternalValidationDenyOnFailure": 1, +# +# alwaysActiveAccounts (list of accounts) +# DESC: List of accounts which should NOT be checked against the accountExternalValidationProgram mechanism above (for example bot accounts). This can also be set per-account at account creation time or later with the accountModify plugin's '--always-active' flag. +# DEFAULT: [] +"alwaysActiveAccounts": [], +# +# allowedIngressSshAlgorithms (array of algorithm names), deprecated alias: allowedSshAlgorithms +# DESC: the algorithms authorized for ingress ssh public keys added to this bastion. possible values: dsa, rsa, ecdsa, ed25519, note that some of those might not be supported by your current version of OpenSSH, unsupported algorithms are automatically omitted at runtime +# DEFAULT: [ "rsa", "ecdsa", "ed25519" ] +"allowedIngressSshAlgorithms": [ "rsa", "ecdsa", "ed25519" ], +# +# allowedEgressSshAlgorithms (array of algorithm names), deprecated alias: allowedSshAlgorithms +# aliases: allowedSshAlgorithms (deprecated) +# DESC: the algorithms authorized for egress ssh public keys generated on this bastion. possible values: dsa, rsa, ecdsa, ed25519, note that some of those might not be supported by your current version of OpenSSH, unsupported algorithms are automatically omitted at runtime +# DEFAULT: [ "rsa", "ecdsa", "ed25519" ] +"allowedEgressSshAlgorithms": [ "rsa", "ecdsa", "ed25519" ], +# +# minimumIngressRsaKeySize (int), deprecated alias: minimumRsaKeySize +# DESC: The minimum allowed size for ingress RSA keys (user->bastion). Sane values range from 2048 to 4096. +# DEFAULT: 2048 +"minimumIngressRsaKeySize": 4096, +# +# maximumIngressRsaKeySize (int) +# DESC: The maximum allowed size for ingress RSA keys (user->bastion). Too big values (>8192) are extremely CPU intensive and don't really add that much security. +# DEFAULT: 8192 +"maximumIngressRsaKeySize": 8192, +# +# minimumEgressRsaKeySize (int), deprecated alias: minimumRsaKeySize +# DESC: The minimum allowed size for egress RSA keys (bastion->server). Sane values range from 2048 to 4096. +# DEFAULT: 2048 +"minimumEgressRsaKeySize": 4096, +# +# maximumEgressRsaKeySize (int) +# DESC: The maximum allowed size for ingress RSA keys (bastion->server). Too big values (>8192) are extremely CPU intensive and don't really add that much security. +# DEFAULT: 8192 +"maximumEgressRsaKeySize": 8192, +# +# defaultAccountEgressKeyAlgorithm (string) +# DESC: the default algorithm to use to create the egress key of a newly created account +# DEFAULT: rsa +"defaultAccountEgressKeyAlgorithm": "rsa", +# +# defaultAccountEgressKeySize (int) +# DESC: the default size to use to create the egress key of a newly created account (also see defaultAccountEgressKeyAlgorithm) +# DEFAULT: 4096 +"defaultAccountEgressKeySize": 4096, +# +# sshClientHasOptionE (boolean-int, i.e. 0 or 1) +# DESC: Set to 1 if your ssh client supports the -E option and you want to use it to log debug info on opened sessions. Discouraged, has some annoying side effects (some ssh errors then go silent from the user perspective) +# DEFAULT: 0 +"sshClientHasOptionE": 0, +# +# sshClientDebugLevel (int, 0 to 3) +# DESC: Indicates the number of -v that will be added to the ssh client command line when starting a session. Probably a bad idea unless you want to annoy your users. +# DEFAULT: 0 +"sshClientDebugLevel": 0, +# +# displayLastLogin (int) +# DESC: If != 0, display last login information on connection. +# DEFAULT: 1 +"displayLastLogin": 1, +# +# accountMaxInactiveDays (int) +# DESC: If != 0, deny access to accounts that didn't log in since at least that many days. A value of 0 means that this functionality is disabled (will never deny access). +# DEFAULT: 0 +"accountMaxInactiveDays": 0, +# +# accountExpiredMessage (string) +# DESC: If non-empty, customizes the message that will be printed to a user attempting to connect with an expired account (see accountMaxInactiveDays above). When empty, defaults to the standard message "Sorry, but your account has expired (#DAYS# days), access denied by policy.". +# DEFAULT: "" +"accountExpiredMessage": "", +# +# accountCreateSupplementaryGroups (array) +# DESC: List of groups to add a new account to. Can be useful to grant some restricted commands by default to new accounts. For example osh-selfAddPersonalAccess, osh-selfDelPersonalAccess, etc. +# DEFAULT: [] +"accountCreateSupplementaryGroups": [], +# +# accountCreateDefaultPersonalAccesses (list of IPs and/or prefixes), deprecated alias: accountCreateDefaultPrivateAccesses +# DESC: List of strings of the form USER@IP or USER@IP:PORT or IP or IP:PORT, with IP being IP or prefix (such as 1.2.3.0/24). This is the list of accesses to add to the personal access list of newly created accounts. The special value ACCOUNT is replaced by the name of the account being created. This can be useful to grant some accesses by default to new accounts (for example ACCOUNT@0.0.0.0/0) +# DEFAULT: [] +"accountCreateDefaultPersonalAccesses": [], +# +# accountMFAPolicy (enum) +# DESC: Set a MFA policy for the bastion accounts, see OPTIONS below for the supported policies list +# DEFAULT: enabled +# OPTIONS: +# disabled: the commands to setup TOTP and UNIX account password are disabled, nobody can setup MFA for himself or others. Already configured MFA still applies, unless the sshd configuration is modified to no longer call PAM on the authentication phase +# password-enforced: for all accounts, a UNIX account password is required in addition to the ingress SSH public key. On first connection with his SSH key, the user is forced to setup a password for his account, and can't disable it afterwards +# totp-enforced: for all accounts, a TOTP is required in addition to the ingress SSH public key. On first connection with his SSH key, the user is forced to setup a TOTP for his account, and can't disable it afterwards +# any-enforced: for all accounts, either a TOTP or an UNIX account password is required in addition to the ingress SSH public key. On first connection with his SSH key, the user is forced to setup either of those, as he sees fit, and can't disable it afterwards +# enabled: for all accounts, TOTP and UNIX account password are available as opt-in features as the users see fit. Some accounts can be forced to setup either TOTP or password-based MFA if they're flagged accordingly (with the accountModify command) +"accountMFAPolicy": "enabled", +# +# MFAPasswordMinDays (int >= 0) +# DESC: For the PAM UNIX password MFA, sets the minimum amount of days between two password changes (see `chage -m') +# DEFAULT: 0 +"MFAPasswordMinDays": 0, +# +# MFAPasswordMaxDays (int >= 0) +# DESC: For the PAM UNIX password MFA, sets the maximum amount of days after which the password must be changed (see `chage -M') +# DEFAULT: 90 +"MFAPasswordMaxDays": 90, +# +# MFAPasswordMaxDays (int >= 0) +# DESC: For the PAM UNIX password MFA, sets the number of days before expiration on which the user will be warned to change his password (see `chage -W') +# DEFAULT: 15 +"MFAPasswordMaxDays": 15, +# +# MFAPasswordInactiveDays (int >= -1) +# DESC: For the PAM UNIX password MFA, the account will be blocked after the password is expired (and not renewed) for this amount of days (see `chage -E'). -1 disables this feature. Note that this is different from the accountMaxInactiveDays option above, that is handled by the bastion software itself instead of PAM +# DEFAULT: -1 +"MFAPasswordInactiveDays": -1, +# +# MFAPostCommand (string) +# DESC: When using JIT MFA (i.e. not directly by calling PAM from SSHD's configuration, but using pamtester from within the code), execute this command on success. +# This can be used for example if you're using pam_tally2 in your PAM MFA configuration, pamtester can't reset the counter to zero because this is usually done in the account_mgmt PAM phase. You can use a script to reset it here. +# The magic value %ACCOUNT% will be replaced by the account name. +# DEFAULT: [] +# EXAMPLE: ["sudo","-n","-u","root","--","/sbin/pam_tally2","-u","%ACCOUNT%","-r"], +"MFAPostCommand": [], +# +# remoteCommandEscapeByDefault (boolean-int, i.e. 0 or 1) +# DESC: If set to 0, will not escape simple quotes in remote commands by default. Leave it to 0 if possible. Will escape simple quotes otherwise (legacy "broken" behavior). Can be overriden at runtime with --never-escape and --always-escape +# DEFAULT: 0 +"remoteCommandEscapeByDefault": 0, +# +# readOnlySlaveMode (boolean-int, i.e. 0 or 1) +# DESC: If set to 0, this bastion will work in standalone mode, or will be the master in a master/slave mode. If set to 1, this'll be the slave which means all plugins that modify groups, accounts, or access rights will be disabled, and the master bastion will push its modifications using inotify/rsync, please refer do the documentation to set this up +# DEFAULT: 0 +"readOnlySlaveMode": 0, +# +# interactiveModeAllowed (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, --interactive mode is allowed. Otherwise, this feature is disabled. +# DEFAULT: 0 +"interactiveModeAllowed": 1, +# +# interactiveModeTimeout (int, in seconds) +# DESC: The number of idle seconds after which the user is disconnected from the bastion when in interactive mode. A value of 0 will disable this feature (user will never be disconnected for idle timeout) +# DEFAULT: 60 +"interactiveModeTimeout": 60, +# +# enableSyslog (boolean-int) +# DESC: If set to 0, syslog will be disabled. If set to 1, we'll send logs through syslog (don't forget to setup your syslogd) +# DEFAULT: 1 +"enableSyslog": 1, +# +# syslogFacility (string) +# DESC: Sets the facility that will be used for syslog +# DEFAULT: local7 +"syslogFacility": "local7", +# +# syslogDescription (string) +# DESC: Sets the description that will be used for syslog +# DEFAULT: bastion +"syslogDescription": "bastion", +# +# enableGlobalAccessLog (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, all accesses will still be logged in the old /home/osh.log (never rotated, world-writable -> discouraged). If set to 0, we'll no longer log there (modern way is syslog, see above) +# DEFAULT: 1 +"enableGlobalAccessLog": 1, +# +# enableAccountAccessLog (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, all accesses will still be logged in the user's home /home/USER/USER-log-YYYYMM.log. If set to 0, we won't log there. +# DEFAULT: 1 +"enableAccountAccessLog": 1, +# +# enableGlobalSqlLog (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, all accesses will be logged (in a short SQL format) in /home/logkeeper/*.sqlite. If set to 0, we won't log there. +# DEFAULT: 1 +"enableGlobalSqlLog": 1, +# +# enableAccountSqlLog (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, all accesses will be logged (in a detailed SQL format) in the user's home /home/USER/USER-log-YYYYMM.sqlite. Otherwise, we won't log there. +# DEFAULT: 1 +"enableAccountSqlLog": 1, +# +# moshAllowed (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, mosh usage is allowed (mosh needs to be installed on serverside, obviously). Otherwise, this feature is disabled. +# DEFAULT: 0 +"moshAllowed": 0, +# +# moshTimeoutNetwork (int, >0) +# DESC: Number of seconds of inactivity (network-wise) after a mosh-server will exit. By design even if the client is disconnected "for good", mosh-server would wait forever. If mosh is meant to handle shaky connections but not mobility, you can set this to a low value. It sets the MOSH_SERVER_NETWORK_TMOUT envvar for mosh, see `man mosh-server' for more information (mosh 1.2.6+) +# DEFAULT: 86400 +"moshTimeoutNetwork": 86400, +# +# moshTimeoutSignal (int, >0) +# DESC: Number of seconds of inactivity (network-wise) a mosh-server will wait after receiving a SIGUSR1 before exiting. It sets the MOSH_SERVER_SIGNAL_TMOUT envvar for mosh, see `man mosh-server' for more information (mosh 1.2.6+) +# DEFAULT: 30 +"moshTimeoutSignal": 30, +# +# moshCommandLine (string) +# DESC: Additional parameters that will be passed as-is to mosh-server. See `man mosh-server', you should at least add the -p option to specify a fixed number of ports (easier for firewall configuration) +# DEFAULT: "" +# EXAMPLE: "-s -p 40000:49999" +"moshCommandLine": "", +# +# keyboardInteractiveAllowed (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, will allow keyboard-interactive authentication when publickey auth is requested for egress connections, this is needed e.g. for 2FA +# DEFAULT: 0 +"keyboardInteractiveAllowed": 1, +# +# passwordAllowed (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, will allow password authentication for egress ssh, so that user can type his remote password interactively +# DEFAULT: 0 +"passwordAllowed": 0, +# +# telnetAllowed (boolean-int, i.e. 0 or 1) +# DESC: If set to 1, will allow telnet egress connections (-e / --telnet), +# DEFAULT: 0 +"telnetAllowed": 0, +# +# idleLockTimeout (int, >=0) +# DESC: If set to a positive value >0, the number of seconds of input idle time after which the session is locked. If 0, disabled. +# DEFAULT: 0 +"idleLockTimeout": 0, +# +# idleKillTimeout (int, >=0) +# DESC: If set to a positive value >0, the number of seconds of input idle time after which the session is killed. If 0, disabled. If idleLockTimeout is set, this value must be higher (obviously). +# DEFAULT: 0 +"idleKillTimeout": 0, +# +# warnBeforeLockSeconds (int, >=0) +# DESC: If set to a positive value >0, the number of seconds before idleLockTimeout where the user will receive a warning message telling him about the upcoming lock of his session. +# DEFAULT: 0 +"warnBeforeLockSeconds": 0, +# +# warnBeforeKillSeconds (int, >=0) +# DESC: If set to a positive value >0, the number of seconds before idleKillTimeout where the user will receive a warning message telling him about the upcoming kill of his session. +# DEFAULT: 0 +"warnBeforeKillSeconds": 0, +# +# ttyrecFilenameFormat (string) +# DESC: Sets the filename format of the output files of ttyrec for a given session. Magic tokens are: &bastionname, &uniqid, &account, &ip, &port, &user (they'll be replaced by the corresponding values of the current session). Then, this string (automatically prepended with the correct folder) will be passed to ttyrec's -F parameter, which uses strftime() to expand it, so the usual character conversions will be done (%Y for the year, %H for the hour, etc., see man strftime). Note that in a addition to the usual strftime() conversion specifications, ttyrec also supports #usec#, to be replaced by the current microsecond value of the time. +# DEFAULT: %Y-%m-%d.%H-%M-%S.#usec#.&uniqid.ttyrec +"ttyrecFilenameFormat": "%Y-%m-%d.%H-%M-%S.#usec#.&uniqid.&account.&user.&ip.&port.ttyrec", +# +# ttyrecAdditionalParameters (list of parameters) +# DESC: Additional parameters you want to pass to ttyrec invocation. Useful, for example, to enable on-the-fly compression, disable cheatcodes, or set/unset any other ttyrec option. This is an ARRAY, not a string. e.g. ["-s", "This is a message with spaces", "--zstd"] +# DEFAULT: [] +"ttyrecAdditionalParameters": [], +# +# documentationURL (string) +# DESC: The URL of the documentation where users will be pointed to, for example when displaying help. If you have some internal documentation about the bastion, you might want to advertise it here. +# DEFAULT: "https://ovh.github.io/bastion/" +"documentationURL": "https://ovh.github.io/the-bastion/" +} diff --git a/etc/bastion/luks-config.sh.dist b/etc/bastion/luks-config.sh.dist new file mode 100644 index 0000000..02e94e4 --- /dev/null +++ b/etc/bastion/luks-config.sh.dist @@ -0,0 +1,6 @@ +# This file will be sourced, IT MUST BE A VALID SHELL SCRIPT +# +DEV_ENCRYPTED=/dev/disk/by-id/dm-name-vg0-home +UNLOCKED_NAME=home +MOUNTPOINT=/home + diff --git a/etc/bastion/osh-backup-acl-keys.conf.dist b/etc/bastion/osh-backup-acl-keys.conf.dist new file mode 100644 index 0000000..ef3d6f0 --- /dev/null +++ b/etc/bastion/osh-backup-acl-keys.conf.dist @@ -0,0 +1,30 @@ +# THIS MUST BE A VALID SHELL SCRIPT +# +# LOGFILE: file where to put script logs, if not defined, will not log into a file +#LOGFILE=/var/log/bastion/osh-backup-acl-keys.log +# +# LOG_FACILITY: will use syslog with the following facility to log, won't use syslog otherwise +LOG_FACILITY=local6 +# +# DESTDIR: directory where to put the .tar.gz files (it'll be created if needed) +DESTDIR=/root/backups +# +# DAYSTOKEEP: number of days to keep backups for +DAYSTOKEEP=90 +# +# GPGKEYS: list of gpg keys to encrypt to (see gpg --list-keys), separated by spaces. +# example: GPGKEYS="41FDB9C7 DA97EFD1 339483FF" +# if omitted or empty, archives will NOT be encrypted +GPGKEYS="" +# +# PUSH_REMOTE: scp remote host push backups to +# example: "push@1.2.3.4:~/backup/" +# important: this will ONLY be enabled if GPGKEYS is not empty above, because +# we will never push unencrypted backups! +# Also, don't forget to put a trailing / (except if you want to push to the +# remote HOME, in which case ending with a simple : works) +PUSH_REMOTE="" +# +# PUSH_OPTIONS: additional options to pass to scp +# example: "-i $HOME/.ssh/id_backup" +PUSH_OPTIONS="" diff --git a/etc/bastion/osh-encrypt-rsync.conf.dist b/etc/bastion/osh-encrypt-rsync.conf.dist new file mode 100644 index 0000000..0959520 --- /dev/null +++ b/etc/bastion/osh-encrypt-rsync.conf.dist @@ -0,0 +1,96 @@ +################################################################### +# Config for /opt/bastion/bin/admin/osh-encrypt-rsync.pl +# This is a JSON file. +################################################################### + +{ +# logfile (string) +# value: filename with full absolute path +# desc: file where the logs will be written to (don't forget to configure logrotate!) +# default: none (won't log to any file) +# optional +# "logfile": "/var/log/bastion/osh-encrypt-rsync.log", + +# syslog_facility (string) +# value: syslog facility to log to +# desc: if not defined, we'll not use syslog +# default: local6 +# optional +"syslog_facility": "local6", + +# signing_key (string) +# value: GPG key ID (short or long) +# desc: ID of the key used to sign the ttyrec files (must be in the local keyring, cf gpg --list-secret-keys) +# default: - +# mandatory +"signing_key": "FFFFFFFF", + +# signing_key_passphrase (string) +# value: the passphrase of the signing_key +# desc: will be used by the script to unlock the key and sign with it +# default: - +# mandatory +"signing_key_passphrase": "configure_this_passphrase", + +# encryption_recipients (array of array of strings) +# value: strings must be GPG key IDs (short or long) +# desc: ttyrecs will be encrypted with those GPG keys, possibly using multi-layer GPG encryption. +# Each sub-array is a layer, the first sub-array being the first encryption layer (which is also the last one for decryption) +# To completely decrypt a ttyrec, one would need at least one key of each layer. +# To encrypt only to a single layer and to only one key, simply use [ [ "KEYID" ] ]. +# To encrypt to a single layer but with 3 keys being able to decrypt the ttyrec, use [ [ "KEY1", "KEY2", "KEY3" ] ], etc. +# A common use of multi-layer encryption is to have the first layer composed of the auditors' GPG keys, and +# the second layer composed of the sysadmins' GPG keys. During an audit, the sysadmins would get the ttyrec encrypted file, +# decrypt the second encryption layer (the first for decryption), and handle the now only auditor-protected file to the auditors. +# All public keys must be in the local keyring (gpg --list-keys). +# Don't forget to trust those keys "ultimately" in your keyring, too (gpg --edit-key ID) +# default: - +# mandatory +"recipients": [ + [ "AAAAAAAA", "BBBBBBBB" ], + [ "CCCCCCCC", "DDDDDDDD" ] +], + +# encrypt_and_move_to_directory (string) +# value: directory name (will be created if doesn't exist) +# desc: after encryption (and compression), move ttyrec files to subdirs of this directory +# default: - +# mandatory +"encrypt_and_move_to_directory": "/home/.encrypt", + +# encrypt_and_move_delay_days (integer) +# value: int > 0 +# desc: don't touch ttyrec files that have a modification time more recent than this +# default: - +# mandatory +"encrypt_and_move_delay_days": 14, + +# rsync_destination (string) +# value: user@remotehost:/remote/dir/ +# desc: string passed to rsync as a destination. If empty, will DISABLE rsync. +# default: "" +# example: user@remotebackup:/remote/dir +# mandatory +"rsync_destination": "", + +# rsync_rsh (string) +# value: any valid string that'll be passed to rsync's --rsh option +# desc: useful to specify an SSH key or an alternate SSH port for example. Not used if rsync is disabled. +# example: ssh -p 222 -i /home/plop/.ssh/id_rsa_backup +# default: none (--rsh won't be specified) +# optional +"rsync_rsh": "ssh -p 222 -i /root/.ssh/id_rsa_backup", + +# rsync_delay_before_remove_days (integer) +# value: int >= 0 +# desc: after encryption/compression, and successful rsync to remote, wait for this amount of days before removing the files locally. Not used if rsync is disabled. +# default: - +# mandatory +"rsync_delay_before_remove_days": 7 +} + + + + + + diff --git a/etc/bastion/osh-http-proxy.conf.dist b/etc/bastion/osh-http-proxy.conf.dist new file mode 100644 index 0000000..a6dc1ee --- /dev/null +++ b/etc/bastion/osh-http-proxy.conf.dist @@ -0,0 +1,78 @@ +############################################################################################ +# Config for the HTTP Proxy of The Bastion. +# This is a JSON file, its syntax must be valid at all times. To verify: +# => grep -v ^# /etc/bastion/osh-http-proxy.conf|python -mjson.tool>/dev/null && echo OK +# +# If you're on a production bastion you can verify it can properly load its configuration: +# => perl -I/opt/bastion/lib/perl -MOVH::Bastion -e 'die OVH::Bastion::load_configuration_file(file => "/etc/bastion/osh-http-proxy.conf")' +############################################################################################ +{ +# enabled (bool) +# VALUE: true or false +# DESC: whether the http proxy daemon is enabled or not (if not, it'll exit when launched) +# DEFAULT: false +"enabled": false, +# +# port (int) +# VALUE: 1 to 65535 +# DESC: port to listen to. you can set < 1024, in which case privileges will be dropped after binding, +# but please ensure your systemd unit file starts the daemon as root in that case +# DEFAULT: 8443 +"port": 8443, +# +# ssl_certificate (string) +# VALUE: a full path to a file +# DESC: file that contains the server SSL certificate in PEM format. For tests, install the ssl-cert package and point to snakeoil (which is the default). +# DEFAULT: /etc/ssl/private/ssl-cert-snakeoil.key +"ssl_certificate": "/etc/ssl/certs/ssl-cert-snakeoil.pem", +# +# ssl_key (string) +# VALUE: a full path to a file +# DESC: file that contains the server SSL key in PEM format. For tests, install the ssl-cert package and point to snakeoil (which is the default). +# DEFAULT: /etc/ssl/private/ssl-cert-snakeoil.key +"ssl_key": "/etc/ssl/private/ssl-cert-snakeoil.key", +# +# ciphers (string) +# VALUE: openssl-compatible colon-separated (':') ciphersuites +# DESC: the ordered list the TLS server ciphers, in openssl classic format. Use `openssl ciphers' to see what your system supports, +# an empty list leaves the choice to your openssl libraries default values (system-dependent) +# EXAMPLE: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" +# DEFAULT: "" +"ciphers": "", +# +# insecure (bool) +# VALUE: true or false +# DESC: whether to ignore SSL certificate verification for the connection between the bastion and the devices +# DEFAULT: false +"insecure": false, +# +# min_servers (int) +# VALUE: 1 to 512 +# DESC: number of child processes to start at launch +# DEFAULT: 8 +"min_servers": 8, +# +# max_servers (int) +# VALUE: 1 to 512 +# DESC: hard maximum number of child processes that can be active at any given time no matter what +# DEFAULT: 32 +"max_servers": 32, +# +# min_spare_servers (int) +# VALUE: 1 to 512 +# DESC: the daemon will ensure that there is at least this number of children idle & ready to accept new connections (as long as max_servers is not reached) +# DEFAULT: 8 +"min_spare_servers": 8, +# +# max_spare_servers (int) +# VALUE: 1 to 512 +# DESC: the daemon will kill *idle* children to keep their number below this maximum when traffic is low +# DEFAULT: 16 +"max_spare_servers": 16, +# +# timeout (int) +# VALUE: 1 to 3600 +# DESC: timeout delay (in seconds) for the connection between the bastion and the devices +# DEFAULT: 120 +"timeout": 120 +} diff --git a/etc/bastion/osh-piv-grace-reaper.conf.dist b/etc/bastion/osh-piv-grace-reaper.conf.dist new file mode 100644 index 0000000..890613d --- /dev/null +++ b/etc/bastion/osh-piv-grace-reaper.conf.dist @@ -0,0 +1,3 @@ +{ + "SyslogFacility": "local6" +} diff --git a/etc/bastion/osh-sync-watcher.rsyncfilter.dist b/etc/bastion/osh-sync-watcher.rsyncfilter.dist new file mode 100644 index 0000000..d4c0223 --- /dev/null +++ b/etc/bastion/osh-sync-watcher.rsyncfilter.dist @@ -0,0 +1,40 @@ +- /home/oldkeeper/ +- /home/logkeeper/ +- /home/.*/ +- /home/backup*/ +- /home/admin/ +- /home/bastionsync/ +- /home/lost+found/ +- /home/allowkeeper/activeLogin.json +- /home/allowkeeper/maintenance + ++ / + ++ /etc/ ++ /etc/passwd ++ /etc/group ++ /etc/shadow ++ /etc/gshadow ++ /etc/sudoers.d/ ++ /etc/sudoers.d/osh-* ++ /etc/ssh/ ++ /etc/ssh/ssh_host_*_key ++ /etc/ssh/ssh_host_*_key.pub +- /etc/** + ++ /home/ + ++ /home/*/ttyrec/ +- /home/*/ttyrec/** +- /home/*/.bash* +- /home/*/*.db +- /home/*/*.sqlite +- /home/*/*.sqlite-* +- /home/*/*.log +- /home/*/*.gz +- /home/*/lastlog +- /home/*/.ssh/known_hosts + ++ /home/*/*** + +- * diff --git a/etc/bastion/osh-sync-watcher.sh.dist b/etc/bastion/osh-sync-watcher.sh.dist new file mode 100644 index 0000000..e704d28 --- /dev/null +++ b/etc/bastion/osh-sync-watcher.sh.dist @@ -0,0 +1,40 @@ +# this is a SHELL SCRIPT, it'll be sourced by osh-sync-watcher.sh +# +# timeout (integer) +# this will be the maximum delay, in seconds, after which rsync will be launched even if no change was detected +# example: +#timeout=120 +timeout=120 +# +# rshcmd (string) +# this will be passed as the --rsh parameter of rsync (don't use -p to specify port, use the remotehostlist config instead) +# example: +#rshcmd="ssh -q -i /root/.ssh/id_master2slave" +rshcmd="ssh -q -i /root/.ssh/id_master2slave" +# +# remoteuser (string) +# remote user to connect as while rsyncing +# example: +#remoteuser=bastionsync +remoteuser=bastionsync +# +# remotelist (space-separated list of strings, each string being either 'ip' or 'ip:port') +# remote hosts to connect to while rsyncing (aka the list of the slave bastions) +# example: +#remotehostlist=192.0.2.17 +#remotehostlist='192.0.2.11 192.0.2.12' +remotehostlist=___PLEASE_CONFIGURE_ME___ +# +# enabled (integer) +# if set to anything else than 1, the script will not run +# set this to 1 when you've configured and tested the setup +enabled=0 +# +# logdir (string) +# directory where to log output from the script +# if unset, will NOT log to a file +#logdir=/var/log/bastion +# +# syslog (string) +# syslog facility to use, if unset, will NOT log to syslog +syslog=local6 diff --git a/etc/cron.d/osh-backup-acl-keys.dist b/etc/cron.d/osh-backup-acl-keys.dist new file mode 100644 index 0000000..43b46a0 --- /dev/null +++ b/etc/cron.d/osh-backup-acl-keys.dist @@ -0,0 +1,2 @@ +# Backup locally critical files (keys, passwords, ACLs) +%RANDOM1%0:59% %RANDOM2%3:23% * * * root /opt/bastion/bin/cron/osh-backup-acl-keys.sh >/dev/null diff --git a/etc/cron.d/osh-compress-old-logs.dist b/etc/cron.d/osh-compress-old-logs.dist new file mode 100644 index 0000000..904a994 --- /dev/null +++ b/etc/cron.d/osh-compress-old-logs.dist @@ -0,0 +1,2 @@ +# compress sqlite databases > 1 month along with log files +%RANDOM1%0:59% %RANDOM2%4:7% * * * root /opt/bastion/bin/cron/osh-compress-old-logs.sh >/dev/null diff --git a/etc/cron.d/osh-encrypt-rsync-ttyrec.dist b/etc/cron.d/osh-encrypt-rsync-ttyrec.dist new file mode 100644 index 0000000..9eaa9da --- /dev/null +++ b/etc/cron.d/osh-encrypt-rsync-ttyrec.dist @@ -0,0 +1,2 @@ +# Encrypt ttyrecs, and move them to a remote location when needed +%RANDOM1%0:59% %RANDOM2%3:23% * * * root /opt/bastion/bin/cron/osh-encrypt-rsync.pl >/dev/null diff --git a/etc/cron.d/osh-lingering-sessions-reaper.dist b/etc/cron.d/osh-lingering-sessions-reaper.dist new file mode 100644 index 0000000..ba2ef51 --- /dev/null +++ b/etc/cron.d/osh-lingering-sessions-reaper.dist @@ -0,0 +1,2 @@ +# detect lingering sessions (for >1 day) with nobody on the other side (no tty), and kill them +15 5 * * * root /opt/bastion/bin/cron/osh-lingering-sessions-reaper.sh >/dev/null diff --git a/etc/cron.d/osh-orphaned-homedir.dist b/etc/cron.d/osh-orphaned-homedir.dist new file mode 100644 index 0000000..f22d794 --- /dev/null +++ b/etc/cron.d/osh-orphaned-homedir.dist @@ -0,0 +1,2 @@ +# On slaves, remove sparse orphaned directories in /home due to deleted accounts/groups +*/5 * * * * root /opt/bastion/bin/cron/osh-orphaned-homedir.sh >/dev/null diff --git a/etc/cron.d/osh-piv-grace-reaper.dist b/etc/cron.d/osh-piv-grace-reaper.dist new file mode 100644 index 0000000..e2d1e1f --- /dev/null +++ b/etc/cron.d/osh-piv-grace-reaper.dist @@ -0,0 +1,2 @@ +# Check each 5 minutes that we don't have any expired grace period for PIV enforcement +*/5 * * * * root /opt/bastion/bin/cron/osh-piv-grace-reaper.pl >/dev/null diff --git a/etc/cron.d/osh-rotate-ttyrec.dist b/etc/cron.d/osh-rotate-ttyrec.dist new file mode 100644 index 0000000..098801f --- /dev/null +++ b/etc/cron.d/osh-rotate-ttyrec.dist @@ -0,0 +1,4 @@ +# Send a signal SIGUSR1 every day, to force rotation of ttyrec log +31 5 * * * root /opt/bastion/bin/cron/osh-rotate-ttyrec.sh >/dev/null +# And to big ttyrec files to ensure a user cat'ing /dev/urandom won't fill up our drives +*/3 * * * * root /opt/bastion/bin/cron/osh-rotate-ttyrec.sh --big-only >/dev/null diff --git a/etc/init.d/osh-http-proxy b/etc/init.d/osh-http-proxy new file mode 100644 index 0000000..2bddc8c --- /dev/null +++ b/etc/init.d/osh-http-proxy @@ -0,0 +1,63 @@ +#! /bin/sh +# +# osh-http-proxy-daemon: The Bastion HTTPS proxy daemon +# +### BEGIN INIT INFO +# Provides: osh-http-proxy-daemon +# Required-Start: $network +# Required-Stop: $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Starts the bastion http proxy at boot time +# Description: Script to start/stop/reload the osh-http-proxy-daemon daemon +### END INIT INFO + +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +NAME=osh-http-proxy-daemon +USER=proxyhttp +DAEMON=/opt/bastion/bin/proxy/$NAME +DESC="listens for HTTPS connections" +SCRIPTNAME=/etc/init.d/$NAME +# don't change the pidfile location because it's also in the script +PIDFILE=/var/run/$NAME.pid + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +test -x $DAEMON || exit 0 + +case "$1" in + start) + echo -n "Starting osh-http-proxy-daemon daemon" + start-stop-daemon --start --background --chuid $USER --exec $DAEMON + echo "." + ;; + stop) + echo -n "Shutting down osh-http-proxy-daemon daemon... " + if pgrep -c -f $DAEMON >/dev/null ; then + pkill -f $DAEMON + echo done + else + echo "not running" + fi + ;; + force-reload|restart) + echo -n "Restarting osh-http-proxy-daemon daemon" + if pgrep -c -f $DAEMON >/dev/null ; then + pkill -f $DAEMON + echo done + else + echo "not running" + fi + start-stop-daemon --start --background --exec $DAEMON + echo "." + ;; + *) + echo "Usage: $0 {start|stop|restart|force-reload}" + exit 1 +esac +exit 0 diff --git a/etc/init.d/osh-sync-watcher b/etc/init.d/osh-sync-watcher new file mode 100644 index 0000000..3c1334c --- /dev/null +++ b/etc/init.d/osh-sync-watcher @@ -0,0 +1,63 @@ +#! /bin/sh +# +# osh-sync-watcher: The Bastion sync daemon +# +### BEGIN INIT INFO +# Provides: osh-sync-watcher +# Required-Start: $network +# Required-Stop: $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Starts the bastion master-slave sync watcher at boot time +# Description: Script to start/stop/reload the osh-sync-watcher daemon +### END INIT INFO + +PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin +DAEMON=/opt/bastion/bin/admin/osh-sync-watcher.sh +NAME=osh-sync-watcher +DESC="syncs the master bastion to the slave" +SCRIPTNAME=/etc/init.d/$NAME +# don't change the pidfile location because it's also in the script +PIDFILE=/var/run/osh-sync-watcher.pid + +# Load the VERBOSE setting and other rcS variables +. /lib/init/vars.sh + +# Define LSB log_* functions. +# Depend on lsb-base (>= 3.0-6) to ensure that this file is present. +. /lib/lsb/init-functions + +test -x $DAEMON || exit 0 + +case "$1" in + start) + echo -n "Starting osh-sync-watcher daemon" + start-stop-daemon --start --background --exec $DAEMON + echo "." + ;; + stop) + echo -n "Shutting down osh-sync-watcher daemon... " + if pgrep -c -f $DAEMON >/dev/null ; then + pkill -f $DAEMON + echo done + else + echo "not running" + fi + ;; + force-reload|restart) + echo -n "Restarting osh-sync-watcher daemon" + if pgrep -c -f $DAEMON >/dev/null ; then + pkill -f $DAEMON + echo done + else + echo "not running" + fi + start-stop-daemon --start --background --exec $DAEMON + echo "." + ;; + *) + echo "Usage: $0 {start|stop|restart|force-reload}" + exit 1 +esac +exit 0 + diff --git a/etc/logrotate.d/osh-backup-acl-keys.dist b/etc/logrotate.d/osh-backup-acl-keys.dist new file mode 100644 index 0000000..c76db83 --- /dev/null +++ b/etc/logrotate.d/osh-backup-acl-keys.dist @@ -0,0 +1,10 @@ +/var/log/bastion/osh-backup-acl-keys.log { + notifempty + missingok + rotate 12 + monthly + compress + delaycompress + create 0600 root root +} + diff --git a/etc/logrotate.d/osh-encrypt-rsync.dist b/etc/logrotate.d/osh-encrypt-rsync.dist new file mode 100644 index 0000000..e0d62c1 --- /dev/null +++ b/etc/logrotate.d/osh-encrypt-rsync.dist @@ -0,0 +1,10 @@ +/var/log/bastion/osh-encrypt-rsync.log { + notifempty + missingok + rotate 12 + weekly + compress + delaycompress + create 0600 root root +} + diff --git a/etc/logrotate.d/osh-http-proxy.dist b/etc/logrotate.d/osh-http-proxy.dist new file mode 100644 index 0000000..907aea1 --- /dev/null +++ b/etc/logrotate.d/osh-http-proxy.dist @@ -0,0 +1,10 @@ +/home/proxyhttp/access.log { + notifempty + missingok + rotate 52 + weekly + compress + copytruncate + dateext +} + diff --git a/etc/logrotate.d/osh-sync-watcher.dist b/etc/logrotate.d/osh-sync-watcher.dist new file mode 100644 index 0000000..087613d --- /dev/null +++ b/etc/logrotate.d/osh-sync-watcher.dist @@ -0,0 +1,9 @@ +/var/log/bastion/osh-sync-watcher.log { + notifempty + missingok + rotate 52 + weekly + compress + create 0600 root root +} + diff --git a/etc/logrotate.d/osh-syslog.dist b/etc/logrotate.d/osh-syslog.dist new file mode 100644 index 0000000..e94a133 --- /dev/null +++ b/etc/logrotate.d/osh-syslog.dist @@ -0,0 +1,15 @@ +/var/log/bastion/bastion*.log { + notifempty + missingok + rotate 365 + daily + compress + delaycompress + create 0640 root adm + dateext + sharedscripts + postrotate + invoke-rc.d syslog-ng reload > /dev/null + endscript +} + diff --git a/etc/ovh-oco.d/bastion-code-works.conf.dist b/etc/ovh-oco.d/bastion-code-works.conf.dist new file mode 100644 index 0000000..31b8f72 --- /dev/null +++ b/etc/ovh-oco.d/bastion-code-works.conf.dist @@ -0,0 +1,7 @@ +enabled = false +host = 127.0.0.1 +port = 22 +account = healthcheck +keyfile = /home/healthcheck/.ssh/id_healthcheck +kbdinteractive = yes + diff --git a/etc/ovh-oco.d/root-connected-too-long.conf.dist b/etc/ovh-oco.d/root-connected-too-long.conf.dist new file mode 100644 index 0000000..c5c60ff --- /dev/null +++ b/etc/ovh-oco.d/root-connected-too-long.conf.dist @@ -0,0 +1,4 @@ +enabled = true +threshold_crit = 12 +threshold_warn = 1 + diff --git a/etc/pam.d/sshd.debian b/etc/pam.d/sshd.debian new file mode 100644 index 0000000..d38a87c --- /dev/null +++ b/etc/pam.d/sshd.debian @@ -0,0 +1,79 @@ +# PAM configuration for the Secure Shell service + +auth requisite pam_nologin.so + +# --- PASSWORD CHECK SECTION +# proceed in this section ONLY if the user is in group mfa-password-configd, skip it entirely otherwise +auth [success=ignore ignore=ignore default=4] pam_succeed_if.so quiet user ingroup mfa-password-configd +auth optional pam_echo.so Your account has Multi-Factor Authentication enabled, an additional authentication factor is required (password). +auth optional pam_exec.so quiet debug stdout /opt/bastion/bin/shell/pam_exec_pwd_info.sh +# lock account after 6 failures, for 5 minutes +auth required pam_tally2.so onerr=fail deny=6 unlock_time=300 +# then check password +auth required pam_unix.so + +# --- TOTP CHECK SECTION +# if root, force TOTP check, we don't specify nullok so TOTP *has* to be configured for uid 0 +auth [success=ignore ignore=ignore default=1] pam_succeed_if.so quiet uid eq 0 +# [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] == required + module_unknown:ignore +# if you have a recent enough libpam-google-authenticator, you can customize the prompt with the following option: [authtok_prompt=Verification Code (OTP): ] +# you can also add "debug" for more verbose logs (requires a not too old version of the pam module) +auth [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] pam_google_authenticator.so nullok secret=/var/otp/root +# if root, TOTP check has already been done just above, so skip this subsection +auth [success=3 ignore=ignore default=ignore] pam_succeed_if.so quiet uid eq 0 +# else (if not root), proceed in this section ONLY if the user is in group mfa-totp-configd, skip it entirely otherwise +auth [success=ignore ignore=ignore default=2] pam_succeed_if.so quiet user ingroup mfa-totp-configd +auth optional pam_echo.so Multi-Factor Authentication enabled, an additional authentication factor is required (OTP). +auth [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] pam_google_authenticator.so secret=~/.otp + +# Read environment variables from /etc/environment and +# /etc/security/pam_env.conf. +session required pam_env.so # [1] +# In Debian 4.0 (etch), locale-related environment variables were moved to +# /etc/default/locale, so read that as well. +session required pam_env.so user_readenv=1 envfile=/etc/default/locale + +# Disallow non-root logins when /etc/nologin exists. +account required pam_nologin.so + +# Reset counter if auth succeeded +account required pam_tally2.so + +# Uncomment and edit /etc/security/access.conf if you need to set complex +# access limits that are hard to express in sshd_config. +# account required pam_access.so + +# Standard Un*x authorization. +@include common-account + +# Standard Un*x session setup and teardown. +@include common-session + + +# Print the message of the day upon successful login. +session optional pam_motd.so noupdate + +# Print the status of the user's mailbox upon successful login. +#session optional pam_mail.so standard noenv # [1] + +# Set up user limits from /etc/security/limits.conf. +session required pam_limits.so + +# Set the loginuid process attribute. +session required pam_loginuid.so + +# Create a new session keyring. +session optional pam_keyinit.so force revoke + +# SELinux needs to be the first session rule. This ensures that any +# lingering context has been cleared. Without this it is possible that a +# module could execute code in the wrong domain. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close + +# SELinux needs to intervene at login time to ensure that the process starts +# in the proper default security context. Only sessions which are intended +# to run in the user's context should be run after this. +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so open + +# Standard Un*x password updating. +@include common-password diff --git a/etc/pam.d/sshd.rhel b/etc/pam.d/sshd.rhel new file mode 100644 index 0000000..af07407 --- /dev/null +++ b/etc/pam.d/sshd.rhel @@ -0,0 +1,56 @@ +#%PAM-1.0 +# PAM configuration for the Secure Shell service + +# We have to disable pam_nologin as, for some reason, at least with the official CentOS docker images, +# pam_nologin always disable login even when no /etc/nologin is present. +#auth requisite pam_nologin.so + +# --- PASSWORD CHECK SECTION +# proceed in this section ONLY if the user is in group mfa-password-configd, skip it entirely otherwise +auth [success=ignore ignore=ignore default=4] pam_succeed_if.so quiet user ingroup mfa-password-configd +auth optional pam_echo.so Your account has Multi-Factor Authentication enabled, an additional authentication factor is required (password). +auth optional pam_exec.so quiet debug stdout /opt/bastion/bin/shell/pam_exec_pwd_info.sh +# lock account after 6 failures, for 5 minutes +# ... but disabled, as at least the official CentOS docker images don't seem to have pam_tally nor pam_tally2 +#auth required pam_tally2.so onerr=fail deny=6 unlock_time=300 +# then check password +auth required pam_unix.so + +# --- TOTP CHECK SECTION +# if root, force TOTP check, we don't specify nullok so TOTP *has* to be configured for uid 0 +auth [success=ignore ignore=ignore default=1] pam_succeed_if.so quiet uid eq 0 +# [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] == required + module_unknown:ignore +# if you have a recent enough libpam-google-authenticator, you can customize the prompt with the following option: [authtok_prompt=Verification Code (OTP): ] +# you can also add "debug" for more verbose logs (requires a not too old version of the pam module) +auth [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] pam_google_authenticator.so nullok secret=/var/otp/root +# if root, TOTP check has already been done just above, so skip this subsection +auth [success=3 ignore=ignore default=ignore] pam_succeed_if.so quiet uid eq 0 +# else (if not root), proceed in this section ONLY if the user is in group mfa-totp-configd, skip it entirely otherwise +auth [success=ignore ignore=ignore default=2] pam_succeed_if.so quiet user ingroup mfa-totp-configd +auth optional pam_echo.so Multi-Factor Authentication enabled, an additional authentication factor is required (OTP). +auth [success=ok new_authtok_reqd=ok ignore=ignore default=bad module_unknown=ignore] pam_google_authenticator.so secret=~/.otp + + +account required pam_sepermit.so +# We have to disable pam_nologin as, for some reason, at least with the official CentOS docker images, +# pam_nologin always disable login even when no /etc/nologin is present. +#account required pam_nologin.so +# Reset counter if auth succeeded +# ... but disabled, as at least the official CentOS docker images don't seem to have pam_tally nor pam_tally2 +#account required pam_tally2.so +account include password-auth + + +password include password-auth + + +# pam_selinux.so close should be the first session rule +session required pam_selinux.so close +session required pam_loginuid.so +# pam_selinux.so open should only be followed by sessions to be executed in the user context +session required pam_selinux.so open env_params +session required pam_namespace.so +session optional pam_keyinit.so force revoke +session optional pam_motd.so +session include password-auth +session include postlogin diff --git a/etc/profile.d/luks-info.sh b/etc/profile.d/luks-info.sh new file mode 100644 index 0000000..facd63b --- /dev/null +++ b/etc/profile.d/luks-info.sh @@ -0,0 +1,10 @@ +CONFIGFILE=/etc/bastion/luks-config.sh +if [ -r $CONFIGFILE ] ; then + . $CONFIGFILE + if [ -n "$MOUNTPOINT" ] ; then + export PROMPT_COMMAND="test -e $MOUNTPOINT/allowkeeper && LUKSINFO= || LUKSINFO='<>'" + PS1='$LUKSINFO'"$PS1" + fi +fi + + diff --git a/etc/ssh/banner b/etc/ssh/banner new file mode 100644 index 0000000..2f60717 --- /dev/null +++ b/etc/ssh/banner @@ -0,0 +1,4 @@ +*------------------------------------------------------------------------------* +|THIS IS A PRIVATE COMPUTER SYSTEM, UNAUTHORIZED ACCESS IS STRICTLY PROHIBITED.| +|ALL CONNECTIONS ARE LOGGED. IF YOU ARE NOT AUTHORIZED, DISCONNECT NOW. | +*------------------------------------------------------------------------------* diff --git a/etc/ssh/ssh_config.centos7 b/etc/ssh/ssh_config.centos7 new file mode 100644 index 0000000..963cfa1 --- /dev/null +++ b/etc/ssh/ssh_config.centos7 @@ -0,0 +1,118 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.centos8 b/etc/ssh/ssh_config.centos8 new file mode 100644 index 0000000..963cfa1 --- /dev/null +++ b/etc/ssh/ssh_config.centos8 @@ -0,0 +1,118 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.debian10 b/etc/ssh/ssh_config.debian10 new file mode 100644 index 0000000..963cfa1 --- /dev/null +++ b/etc/ssh/ssh_config.debian10 @@ -0,0 +1,118 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.debian7 b/etc/ssh/ssh_config.debian7 new file mode 100644 index 0000000..4be322f --- /dev/null +++ b/etc/ssh/ssh_config.debian7 @@ -0,0 +1,113 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# aes is a trusted standard, only allow it's ctr mode (cbc is not considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# we prefer umac (has been proven secure) then sha2. +# we deny md5 and sha1 +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.debian8 b/etc/ssh/ssh_config.debian8 new file mode 100644 index 0000000..963cfa1 --- /dev/null +++ b/etc/ssh/ssh_config.debian8 @@ -0,0 +1,118 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.debian9 b/etc/ssh/ssh_config.debian9 new file mode 100644 index 0000000..963cfa1 --- /dev/null +++ b/etc/ssh/ssh_config.debian9 @@ -0,0 +1,118 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.default b/etc/ssh/ssh_config.default new file mode 100644 index 0000000..8120415 --- /dev/null +++ b/etc/ssh/ssh_config.default @@ -0,0 +1,114 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +# ... not host-based +HostbasedAuthentication no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 diff --git a/etc/ssh/ssh_config.opensuse15 b/etc/ssh/ssh_config.opensuse15 new file mode 100644 index 0000000..70e117a --- /dev/null +++ b/etc/ssh/ssh_config.opensuse15 @@ -0,0 +1,119 @@ +# Hardened SSH bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With modifications where applicable/needed + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +# mitigates CVE-0216-0778 +UseRoaming no +# other unwanted features +Tunnel no +ForwardAgent no +ForwardX11 no +GatewayPorts no +ControlMaster no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +# for older remote servers (or esoteric hardware), we might need to add: aes256-cbc,aes192-cbc,aes128-cbc +# known gotchas: +# - BSD (https://lists.freebsd.org/pipermail/freebsd-bugs/2013-June/053005.html) needs aes256-gcm@openssh.com,aes128-gcm@openssh.com DISABLED +# - Old Cisco IOS (such as v12.2) only supports aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc +# - Ancient Debians (Sarge) and RedHats (7) only support aes128-cbc,3des-cbc,blowfish-cbc,cast128-cbc,arcfour,aes192-cbc,aes256-cbc,rijndael-cbc@lysator.liu.se,aes128-ctr,aes192-ctr,aes256-ctr +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older remote servers, fallback to the non-etm version of +# the algorithms. we deny md5 entirely. +# for older remote servers (or esoteric hardware), we might need to add: hmac-sha1 +# Known gotchas: +# - Old Cisco IOS (such as v12.2) only supports hmac-sha1,hmac-sha1-96,hmac-md5,hmac-md5-96 +# - Ancient Debians (Sarge) and RedHats (7) only support hmac-md5,hmac-sha1,hmac-ripemd160,hmac-ripemd160@openssh.com,hmac-sha1-96,hmac-md5-96 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +# known gotchas: +# - Windows needs diffie-hellman-group14-sha1 and also needs to NOT have diffie-hellman-group-exchange-sha1 present in the list AT ALL +# - OmniOS 5.11 needs diffie-hellman-group1-sha1 +# - Old Cisco IOS (such as v12.2) only supports diffie-hellman-group1-sha1 +# - Ancient Debians (Sarge) and RedHats (7) only support diffie-hellman-group-exchange-sha1,diffie-hellman-group1-sha1 +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password nor keyboard-interactive +# ... (set to yes if sshpass is to be used) +PasswordAuthentication no +# ChallengeResponseAuthentication=yes forces KbdInteractiveAuthentication=yes in the openssh code! +ChallengeResponseAuthentication yes +KbdInteractiveAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no +GSSAPIDelegateCredentials no +# now we specify the auth methods order we want for manual ssh calls. +# NOTE1: as per the ssh source code, an auth method omitted hereafter +# will not be used, even if set to "yes" above. +# NOTE2: the bastion code (namely, ttyrec), will always set the proper +# value explicitely on command-line (pubkey OR sshpass), so the value +# specified hereafter will be ignored. if you want to force-disable +# a method, set it to "no" in the list above, as those will never be +# overriden by the code. +PreferredAuthentications publickey,keyboard-interactive + +# === LOGIN ### + +# disable escape character use +EscapeChar none + +# detect if a hostkey changed due to DNS spoofing +CheckHostIP yes + +# ignore ssh-agent, only use specified keys (-i) +IdentitiesOnly yes +# disable auto-lookup of ~/.ssh/id_rsa ~/.ssh/id_ecdsa etc. +IdentityFile /dev/non/existent/file + +# carry those vars to the other side (includes LC_BASTION) +SendEnv LANG LC_* + +# allow usage of SSHFP DNS records +VerifyHostKeyDNS ask + +# yell if remote hostkey changed +StrictHostKeyChecking ask + +# === SYSTEM === + +# don't hash the users known_hosts files, in the context of a bastion, this adds no security +HashKnownHosts no + +# send an ssh ping each 57 seconds to the client and disconnect after 5 no-replies +ServerAliveInterval 57 +ServerAliveCountMax 5 + diff --git a/etc/ssh/sshd_config.centos7 b/etc/ssh/sshd_config.centos7 new file mode 100644 index 0000000..71816b8 --- /dev/null +++ b/etc/ssh/sshd_config.centos7 @@ -0,0 +1,139 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# === AuthenticationMethods vs potential root OTP vs potential user MFA === +# 2FA has been configured for root, so we force pubkey+PAM for it +#Match User root +# AuthenticationMethods publickey,keyboard-interactive:pam +# Unconditionally skip PAM auth for members of the bastion-nopam group +Match Group bastion-nopam + AuthenticationMethods publickey +# if in one of the mfa groups, use pam +Match Group mfa-totp-configd + AuthenticationMethods publickey,keyboard-interactive:pam +Match Group mfa-password-configd + AuthenticationMethods publickey,keyboard-interactive:pam +# by default, always ask the publickey (no PAM) +Match All + AuthenticationMethods publickey diff --git a/etc/ssh/sshd_config.centos8 b/etc/ssh/sshd_config.centos8 new file mode 100644 index 0000000..71816b8 --- /dev/null +++ b/etc/ssh/sshd_config.centos8 @@ -0,0 +1,139 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# === AuthenticationMethods vs potential root OTP vs potential user MFA === +# 2FA has been configured for root, so we force pubkey+PAM for it +#Match User root +# AuthenticationMethods publickey,keyboard-interactive:pam +# Unconditionally skip PAM auth for members of the bastion-nopam group +Match Group bastion-nopam + AuthenticationMethods publickey +# if in one of the mfa groups, use pam +Match Group mfa-totp-configd + AuthenticationMethods publickey,keyboard-interactive:pam +Match Group mfa-password-configd + AuthenticationMethods publickey,keyboard-interactive:pam +# by default, always ask the publickey (no PAM) +Match All + AuthenticationMethods publickey diff --git a/etc/ssh/sshd_config.debian10 b/etc/ssh/sshd_config.debian10 new file mode 100644 index 0000000..fa506eb --- /dev/null +++ b/etc/ssh/sshd_config.debian10 @@ -0,0 +1,143 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# don't yell to the world that we're running debian, +# this disables the debian string version on the server hello message +DebianBanner no + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# === AuthenticationMethods vs potential root OTP vs potential user MFA === +# 2FA has been configured for root, so we force pubkey+PAM for it +#Match User root +# AuthenticationMethods publickey,keyboard-interactive:pam +# Unconditionally skip PAM auth for members of the bastion-nopam group +Match Group bastion-nopam + AuthenticationMethods publickey +# if in one of the mfa groups, use pam +Match Group mfa-totp-configd + AuthenticationMethods publickey,keyboard-interactive:pam +Match Group mfa-password-configd + AuthenticationMethods publickey,keyboard-interactive:pam +# by default, always ask the publickey (no PAM) +Match All + AuthenticationMethods publickey diff --git a/etc/ssh/sshd_config.debian7 b/etc/ssh/sshd_config.debian7 new file mode 100644 index 0000000..de5c0b1 --- /dev/null +++ b/etc/ssh/sshd_config.debian7 @@ -0,0 +1,125 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# don't yell to the world that we're running debian, +# this disables the debian string version on the server hello message +DebianBanner no + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with RSA +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# aes is a trusted standard, only allow it's ctr mode (cbc is not considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# we prefer umac (has been proven secure) then sha2. +# we deny md5 and sha1 +MACs umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms diffie-hellman-group-exchange-sha256 + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# Use kernel sandbox mechanisms where possible in unprivilegied processes (seccomp) +UsePrivilegeSeparation sandbox + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# Debian 7 doesn't support the AuthenticationMethods, hence we can't benefit +# from the bastion-nopam group (accountModify --pam-auth-bypass will not work) +#Match Group bastion-nopam +# AuthenticationMethods publickey +#Match All +# AuthenticationMethods publickey,keyboard-interactive:pam diff --git a/etc/ssh/sshd_config.debian8 b/etc/ssh/sshd_config.debian8 new file mode 100644 index 0000000..6d63e19 --- /dev/null +++ b/etc/ssh/sshd_config.debian8 @@ -0,0 +1,146 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# don't yell to the world that we're running debian, +# this disables the debian string version on the server hello message +DebianBanner no + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# Use kernel sandbox mechanisms where possible in unprivilegied processes (seccomp) +UsePrivilegeSeparation sandbox + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# === AuthenticationMethods vs potential root OTP vs potential user MFA === +# 2FA has been configured for root, so we force pubkey+PAM for it +#Match User root +# AuthenticationMethods publickey,keyboard-interactive:pam +# Unconditionally skip PAM auth for members of the bastion-nopam group +Match Group bastion-nopam + AuthenticationMethods publickey +# if in one of the mfa groups, use pam +Match Group mfa-totp-configd + AuthenticationMethods publickey,keyboard-interactive:pam +Match Group mfa-password-configd + AuthenticationMethods publickey,keyboard-interactive:pam +# by default, always ask the publickey (no PAM) +Match All + AuthenticationMethods publickey diff --git a/etc/ssh/sshd_config.debian9 b/etc/ssh/sshd_config.debian9 new file mode 100644 index 0000000..6d63e19 --- /dev/null +++ b/etc/ssh/sshd_config.debian9 @@ -0,0 +1,146 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# don't yell to the world that we're running debian, +# this disables the debian string version on the server hello message +DebianBanner no + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... keyboard interactive (needed for MFA through PAM) +KbdInteractiveAuthentication yes +# ... not kerberos +KerberosAuthentication no +# ... challenge-response (needed for MFA through PAM) +ChallengeResponseAuthentication yes +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# Use kernel sandbox mechanisms where possible in unprivilegied processes (seccomp) +UsePrivilegeSeparation sandbox + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes + +# === AuthenticationMethods vs potential root OTP vs potential user MFA === +# 2FA has been configured for root, so we force pubkey+PAM for it +#Match User root +# AuthenticationMethods publickey,keyboard-interactive:pam +# Unconditionally skip PAM auth for members of the bastion-nopam group +Match Group bastion-nopam + AuthenticationMethods publickey +# if in one of the mfa groups, use pam +Match Group mfa-totp-configd + AuthenticationMethods publickey,keyboard-interactive:pam +Match Group mfa-password-configd + AuthenticationMethods publickey,keyboard-interactive:pam +# by default, always ask the publickey (no PAM) +Match All + AuthenticationMethods publickey diff --git a/etc/ssh/sshd_config.default b/etc/ssh/sshd_config.default new file mode 100644 index 0000000..088ef25 --- /dev/null +++ b/etc/ssh/sshd_config.default @@ -0,0 +1,116 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... not keyboard interactive +KbdInteractiveAuthentication no +# ... not challenge-response +ChallengeResponseAuthentication no +# ... not host-based +HostbasedAuthentication no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# root login is allowed only with public keys, not passwords +# this can be disabled entirely for auditing reasons (forcing admins to use sudo) +PermitRootLogin without-password + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# Use kernel sandbox mechanisms where possible in unprivilegied processes (seccomp) +UsePrivilegeSeparation sandbox + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no diff --git a/etc/ssh/sshd_config.opensuse15 b/etc/ssh/sshd_config.opensuse15 new file mode 100644 index 0000000..973ad31 --- /dev/null +++ b/etc/ssh/sshd_config.opensuse15 @@ -0,0 +1,123 @@ +# Hardened SSHD bastion config -- modify wisely! +# Based on https://wiki.mozilla.org/Security/Guidelines/OpenSSH +# With additional restrictions where applicable + +# -lo and -rt users only have local console login +DenyUsers *-rt +DenyUsers *-lo + +# hardened params follow. every non-needed feature is disabled by default, +# following the principle of least rights and least features (more enabled +# features mean a more important attack surface). + +# === FEATURES === + +# disable non-needed sshd features +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +X11Forwarding no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no +GatewayPorts no + +# === INFORMATION DISCLOSURE === + +# however, display a legal notice for each connection +Banner /etc/ssh/banner + +# don't print the bastion MOTD on connection +PrintMotd no + +# === CRYPTOGRAPHY === + +# enforce the use of ssh version 2 protocol, version 1 is disabled. +# all sshd_config options regarding protocol 1 are therefore omitted. +Protocol 2 + +# only use hostkeys with secure algorithms, and omit the ones using NIST curves +HostKey /etc/ssh/ssh_host_ed25519_key +HostKey /etc/ssh/ssh_host_rsa_key + +# list of allowed ciphers. +# chacha20-poly1305 is a modern cipher, considered very secure +# aes is still the standard, we prefer gcm cipher mode, but also +# allow ctr cipher mode for compatibility (ctr is still considered secure) +# we deny arcfour(rc4), 3des, blowfish and cast +Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr + +# list of allowed message authentication code algorithms. +# etm (encrypt-then-mac) are considered the more secure, we +# prefer umac (has been proven secure) then sha2. +# for older ssh client, fallback to the non-etm version of +# the algorithms. +# we deny md5 and sha1 +MACs umac-128-etm@openssh.com,umac-64-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128@openssh.com,umac-64@openssh.com,hmac-sha2-512,hmac-sha2-256 + +# List of allowed key exchange algorithms. +# we prefer curve25519-sha256 which is considered the most modern/secure, +# and still allow diffie hellman with group exchange using sha256 which is +# the most secure dh-based kex. +# we avoid algorithms based on the disputed NIST curves, and anything based +# on sha1. +KexAlgorithms curve25519-sha256@libssh.org,diffie-hellman-group-exchange-sha256 + +# force rekey every 512M of data or 6 hours of connection, whichever comes first +RekeyLimit 512M 6h + +# === AUTHENTICATION === + +# we allow only public key authentication ... +PubkeyAuthentication yes +# ... not password +PasswordAuthentication no +# ... not keyboard interactive +KbdInteractiveAuthentication no +# ... not kerberos +KerberosAuthentication no +# ... not challenge-response +ChallengeResponseAuthentication no +# ... not host-based +HostbasedAuthentication no +# ... and not gssapi auth. +GSSAPIAuthentication no +GSSAPIKeyExchange no + +# just in case, we also explicitely deny empty passwords +PermitEmptyPasswords no + +# this needs to be set at "yes" to allow PAM keyboard-interactive authentication, +# which is not a security issue because the AuthenticationMethods below force the use of +# either publickey or publickey+keyboard-interactive, hence password-only login is never +# possible, for root or any other account for that matter +PermitRootLogin yes + +# === LOGIN === + +# disconnect after 30 seconds if user didn't log in successfully +LoginGraceTime 30 + +# not more than 1 session per network connection (connection sharing with ssh client's master/shared mode) +MaxSessions 1 + +# maximum concurrent unauth connections to the sshd daemon +MaxStartups 50:30:500 + +# accept LANG and LC_* vars (also includes LC_BASTION) +AcceptEnv LANG LC_* + +# === SYSTEM === + +# sshd log level at verbose in auth facility for auditing purposes +LogLevel VERBOSE +SyslogFacility AUTH + +# check sanity of user HOME dir before allowing user to login +StrictModes yes + +# never use dns (slows down connections) +UseDNS no + +# use PAM facility +UsePAM yes diff --git a/etc/sudoers.account.template.d/100-header.sudoers b/etc/sudoers.account.template.d/100-header.sudoers new file mode 100644 index 0000000..35e5913 --- /dev/null +++ b/etc/sudoers.account.template.d/100-header.sudoers @@ -0,0 +1 @@ +## sudoers file for account %ACCOUNT% diff --git a/etc/sudoers.account.template.d/500-base.sudoers b/etc/sudoers.account.template.d/500-base.sudoers new file mode 100644 index 0000000..148dfb9 --- /dev/null +++ b/etc/sudoers.account.template.d/500-base.sudoers @@ -0,0 +1,7 @@ +# I need to be able to set my own UNIX account password +%ACCOUNT% ALL=(root) NOPASSWD:/usr/bin/env perl -T %BASEPATH%/bin/helper/osh-selfMFASetupPassword --account %ACCOUNT% --step ? +# I need to be able to enroll TOTP +%ACCOUNT% ALL=(root) NOPASSWD:/usr/bin/env perl -T %BASEPATH%/bin/helper/osh-selfMFASetupTOTP --account %ACCOUNT% +# I need to be able to reset my own UNIX account password and TOTP +%ACCOUNT% ALL=(root) NOPASSWD:/usr/bin/env perl -T %BASEPATH%/bin/helper/osh-accountMFAResetPassword --account %ACCOUNT% +%ACCOUNT% ALL=(root) NOPASSWD:/usr/bin/env perl -T %BASEPATH%/bin/helper/osh-accountMFAResetTOTP --account %ACCOUNT% diff --git a/etc/sudoers.d/osh-bastion-config b/etc/sudoers.d/osh-bastion-config new file mode 100644 index 0000000..607d581 --- /dev/null +++ b/etc/sudoers.d/osh-bastion-config @@ -0,0 +1,7 @@ +Defaults env_keep += "PLUGIN_DEBUG OSH_DEBUG ANSI_COLORS_DISABLED UNIQID OSH_KBD_INTERACTIVE OSH_IP_FROM SSH_CONNECTION" + +User_Alias SUPEROWNERS = %osh-admin, %osh-superowner + +# Prevent arbitrary code execution as your user when sudoing to another +# user due to TTY hijacking via TIOCSTI ioctl. +Defaults use_pty diff --git a/etc/sudoers.d/osh-bastion-http-proxy b/etc/sudoers.d/osh-bastion-http-proxy new file mode 100644 index 0000000..b82119d --- /dev/null +++ b/etc/sudoers.d/osh-bastion-http-proxy @@ -0,0 +1,3 @@ +Defaults:proxyhttp env_keep += "PROXY_POST_DATA PROXY_ACCOUNT_PASSWORD REMOTE_ADDR REMOTE_PORT SERVER_ADDR SERVER_PORT REQUEST_URI HTTP_USER_AGENT" + +proxyhttp ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/proxy/osh-http-proxy-worker * diff --git a/etc/sudoers.d/osh-bastion-sync b/etc/sudoers.d/osh-bastion-sync new file mode 100644 index 0000000..04b77f4 --- /dev/null +++ b/etc/sudoers.d/osh-bastion-sync @@ -0,0 +1 @@ +bastionsync ALL=(root) NOPASSWD: /usr/bin/rsync --server * diff --git a/etc/sudoers.d/osh-plugin-accountCreate b/etc/sudoers.d/osh-plugin-accountCreate new file mode 100644 index 0000000..88b0269 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountCreate @@ -0,0 +1 @@ +%osh-accountCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountCreate --type normal * diff --git a/etc/sudoers.d/osh-plugin-accountDelete b/etc/sudoers.d/osh-plugin-accountDelete new file mode 100644 index 0000000..feae1dc --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountDelete @@ -0,0 +1 @@ +%osh-accountDelete ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountDelete * diff --git a/etc/sudoers.d/osh-plugin-accountGeneratePassword b/etc/sudoers.d/osh-plugin-accountGeneratePassword new file mode 100644 index 0000000..d6385f6 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountGeneratePassword @@ -0,0 +1,2 @@ +# to be able to generate an egress password for accounts +%osh-accountGeneratePassword ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountGeneratePassword * diff --git a/etc/sudoers.d/osh-plugin-accountGetPasswordInfo b/etc/sudoers.d/osh-plugin-accountGetPasswordInfo new file mode 100644 index 0000000..669e39c --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountGetPasswordInfo @@ -0,0 +1 @@ +%osh-auditor ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountGetPasswordInfo * diff --git a/etc/sudoers.d/osh-plugin-accountListEgressKeys b/etc/sudoers.d/osh-plugin-accountListEgressKeys new file mode 100644 index 0000000..13e3d2b --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountListEgressKeys @@ -0,0 +1 @@ +%osh-accountListEgressKeys ALL=(keyreader) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListEgressKeys * diff --git a/etc/sudoers.d/osh-plugin-accountListIngressKeys b/etc/sudoers.d/osh-plugin-accountListIngressKeys new file mode 100644 index 0000000..20990e3 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountListIngressKeys @@ -0,0 +1 @@ +%osh-accountListIngressKeys ALL=(keyreader) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListIngressKeys * diff --git a/etc/sudoers.d/osh-plugin-accountListPasswords b/etc/sudoers.d/osh-plugin-accountListPasswords new file mode 100644 index 0000000..a1ddbaa --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountListPasswords @@ -0,0 +1 @@ +%osh-accountListPasswords ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListPasswords * diff --git a/etc/sudoers.d/osh-plugin-accountMFAResetPassword b/etc/sudoers.d/osh-plugin-accountMFAResetPassword new file mode 100644 index 0000000..d244f63 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountMFAResetPassword @@ -0,0 +1 @@ +%osh-accountMFAResetPassword ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountMFAResetPassword --account * diff --git a/etc/sudoers.d/osh-plugin-accountMFAResetTOTP b/etc/sudoers.d/osh-plugin-accountMFAResetTOTP new file mode 100644 index 0000000..28dacb3 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountMFAResetTOTP @@ -0,0 +1 @@ +%osh-accountMFAResetTOTP ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountMFAResetTOTP --account * diff --git a/etc/sudoers.d/osh-plugin-accountModify b/etc/sudoers.d/osh-plugin-accountModify new file mode 100644 index 0000000..e9ba638 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountModify @@ -0,0 +1,2 @@ +# modify parameters/policy of an account +%osh-accountModify ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModify * diff --git a/etc/sudoers.d/osh-plugin-accountModifyCommand b/etc/sudoers.d/osh-plugin-accountModifyCommand new file mode 100644 index 0000000..865c753 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountModifyCommand @@ -0,0 +1,4 @@ +# grant access to a command +%osh-accountGrantCommand ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyCommand --action grant * +# revoke access to a command +%osh-accountRevokeCommand ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyCommand --action revoke * diff --git a/etc/sudoers.d/osh-plugin-accountModifyPersonalAccess b/etc/sudoers.d/osh-plugin-accountModifyPersonalAccess new file mode 100644 index 0000000..1ded457 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountModifyPersonalAccess @@ -0,0 +1,5 @@ +%osh-selfAddPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target self --action add * +%osh-accountAddPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target any --action add * +%osh-selfDelPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target self --action del * +%osh-accountDelPersonalAccess ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountModifyPersonalAccess --target any --action del * + diff --git a/etc/sudoers.d/osh-plugin-accountPIV b/etc/sudoers.d/osh-plugin-accountPIV new file mode 100644 index 0000000..af56cb7 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountPIV @@ -0,0 +1,3 @@ +# modify PIV policy of an account +%osh-accountPIV ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountPIV --step 1 --account * +%osh-accountPIV ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountPIV --step 2 --account * diff --git a/etc/sudoers.d/osh-plugin-accountUnexpire b/etc/sudoers.d/osh-plugin-accountUnexpire new file mode 100644 index 0000000..f4a9fc3 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-accountUnexpire @@ -0,0 +1 @@ +%osh-accountUnexpire ALL=(%bastion-users) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountUnexpire * diff --git a/etc/sudoers.d/osh-plugin-adminMaintenance b/etc/sudoers.d/osh-plugin-adminMaintenance new file mode 100644 index 0000000..1ae1066 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-adminMaintenance @@ -0,0 +1,2 @@ +# to be able to set/remove maintenance mode +%osh-admin ALL=(allowkeeper) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-adminMaintenance * diff --git a/etc/sudoers.d/osh-plugin-adminSudo b/etc/sudoers.d/osh-plugin-adminSudo new file mode 100644 index 0000000..04380d9 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-adminSudo @@ -0,0 +1 @@ +%osh-admin ALL=(ALL) NOPASSWD:/usr/bin/env perl /opt/bastion/bin/shell/osh.pl -c * diff --git a/etc/sudoers.d/osh-plugin-groupCreate b/etc/sudoers.d/osh-plugin-groupCreate new file mode 100644 index 0000000..7887d55 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-groupCreate @@ -0,0 +1 @@ +%osh-groupCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-groupCreate * diff --git a/etc/sudoers.d/osh-plugin-groupDelete b/etc/sudoers.d/osh-plugin-groupDelete new file mode 100644 index 0000000..b6aefb5 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-groupDelete @@ -0,0 +1 @@ +%osh-groupDelete ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-groupDelete * diff --git a/etc/sudoers.d/osh-plugin-realmCreate b/etc/sudoers.d/osh-plugin-realmCreate new file mode 100644 index 0000000..6697c1f --- /dev/null +++ b/etc/sudoers.d/osh-plugin-realmCreate @@ -0,0 +1 @@ +%osh-realmCreate ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountCreate --type realm * diff --git a/etc/sudoers.d/osh-plugin-rootListIngressKeys b/etc/sudoers.d/osh-plugin-rootListIngressKeys new file mode 100644 index 0000000..da69483 --- /dev/null +++ b/etc/sudoers.d/osh-plugin-rootListIngressKeys @@ -0,0 +1 @@ +%osh-rootListIngressKeys ALL=(root) NOPASSWD:/usr/bin/env perl -T /opt/bastion/bin/helper/osh-accountListIngressKeys --account root diff --git a/etc/sudoers.group.template.d/100-header.sudoers b/etc/sudoers.group.template.d/100-header.sudoers new file mode 100644 index 0000000..d8c4def --- /dev/null +++ b/etc/sudoers.group.template.d/100-header.sudoers @@ -0,0 +1 @@ +## sudoers file for group %GROUP% diff --git a/etc/sudoers.group.template.d/500-base.sudoers b/etc/sudoers.group.template.d/500-base.sudoers new file mode 100644 index 0000000..6848cdd --- /dev/null +++ b/etc/sudoers.group.template.d/500-base.sudoers @@ -0,0 +1,27 @@ +# as an owner, we can modify the group settings +SUPEROWNERS, %%GROUP%-owner ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupModify --group %GROUP% * + +# as an owner, we can grant/revoke ownership +SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type owner --group %GROUP% * + +# as an owner, we can grant/revoke gatekeepership +SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type gatekeeper --group %GROUP% * + +# as an owner, we can grant/revoke aclkeepership +SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type aclkeeper --group %GROUP% * + +# as an owner, we can generate an egress password for the group +SUPEROWNERS, %%GROUP%-owner ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupGeneratePassword --group %GROUP% * + +# as a gatekeeper, we can grant/revoke membership +SUPEROWNERS, %%GROUP%-gatekeeper ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type member --group %GROUP% * +# as a gatekeeper, to be able to symlink in /home/allowkeeper/ACCOUNT the /home/%GROUP%/allowed.ip file +SUPEROWNERS, %%GROUP%-gatekeeper ALL=(allowkeeper) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupAddSymlinkToAccount --group %GROUP% * + +# as a gatekeeper, we can grant/revoke a guest access +SUPEROWNERS, %%GROUP%-gatekeeper ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetRole --type guest --group %GROUP% * +# as a gatekeeper, to be able to add the servers to /home/allowkeeper/ACCOUNT/allowed.partial.%GROUP% file +SUPEROWNERS, %%GROUP%-gatekeeper ALL=(allowkeeper) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-accountAddGroupServer --group %GROUP% * + +# as an aclkeeper, we can add/del a server from the group server list in /home/%GROUP%/allowed.ip +SUPEROWNERS, %%GROUP%-aclkeeper ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupAddServer --group %GROUP% * diff --git a/etc/syslog-ng/conf.d/20-bastion.conf.dist b/etc/syslog-ng/conf.d/20-bastion.conf.dist new file mode 100644 index 0000000..95e2844 --- /dev/null +++ b/etc/syslog-ng/conf.d/20-bastion.conf.dist @@ -0,0 +1,104 @@ +# Example configuration file for syslog-ng +# The default s_src source is supposed to exist and contain at least system() +# This file should be copied to your /etc/syslog-ng/conf.d directory +# +# Don't forget to logrotate! (included in logrotate.d/bastion-syslog) +# +# Also don't forget to exclude bastion logs from system-wide logs, by excluding +# the filter(f_bastion) from those, under debian it usually means: +# +# filter f_syslog3 { not facility(auth, authpriv, mail) and not filter(f_debug) and not filter(f_bastion); }; +# filter f_messages { level(info,notice,warn) and +# not facility(auth,authpriv,cron,daemon,mail,news) and not filter(f_bastion); }; + + +# we define destinations, might be a good idea to log to a remote syslog in addition to locally + +destination d_bastion_all { + file("/var/log/bastion/bastion.log" + perm(0640) dir_perm(0750) create_dirs(yes) + ); +}; + +destination d_bastion_warn { + file("/var/log/bastion/bastion-warn.log" + perm(0640) dir_perm(0750) create_dirs(yes) + ); +}; + +destination d_bastion_die { + file("/var/log/bastion/bastion-die.log" + perm(0640) dir_perm(0750) create_dirs(yes) + ); +}; + +destination d_bastion_security { + file("/var/log/bastion/bastion-security.log" + perm(0640) dir_perm(0750) create_dirs(yes) + ); +}; + +# this filter catches all bastion syslogs + +filter f_bastion { + facility(local7); + match("bastion" value("PROGRAM") type("string")); +}; + +# split message just to get the msgtype and filter on it + +parser p_bastion_msg { + csv-parser(columns("BASTION.MSGTYPE", "BASTION.PAYLOAD") + delimiters(" ") + flags(greedy) + flags(escape-none) + ); +}; + +# then the 3 specific message types + +filter f_bastion_warn { + filter(f_bastion); + match("warn" value("BASTION.MSGTYPE") type("string")); +}; + +filter f_bastion_die { + filter(f_bastion); + match("die" value("BASTION.MSGTYPE") type("string")); +}; + +filter f_bastion_security { + filter(f_bastion); + match("security" value("BASTION.MSGTYPE") type("string")); +}; + +# finally, we use our filters and destinations here + +log { + source(s_src); + parser(p_bastion_msg); + filter(f_bastion); + destination(d_bastion_all); +}; + +log { + source(s_src); + parser(p_bastion_msg); + filter(f_bastion_warn); + destination(d_bastion_warn); +}; + +log { + source(s_src); + parser(p_bastion_msg); + filter(f_bastion_die); + destination(d_bastion_die); +}; + +log { + source(s_src); + parser(p_bastion_msg); + filter(f_bastion_security); + destination(d_bastion_security); +}; + diff --git a/etc/systemd/osh-http-proxy.service b/etc/systemd/osh-http-proxy.service new file mode 100644 index 0000000..386948d --- /dev/null +++ b/etc/systemd/osh-http-proxy.service @@ -0,0 +1,13 @@ +[Unit] +Description=OVH::Bastion HTTP Proxy daemon + +[Service] +ExecStart=/opt/bastion/bin/proxy/osh-http-proxy-daemon +KillMode=process +Restart=on-failure +RestartSec=5s +User=proxyhttp +Group=proxyhttp + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/osh-sync-watcher.service b/etc/systemd/osh-sync-watcher.service new file mode 100644 index 0000000..6df0718 --- /dev/null +++ b/etc/systemd/osh-sync-watcher.service @@ -0,0 +1,11 @@ +[Unit] +Description=OVH::Bastion master-slave synchronization daemon + +[Service] +ExecStart=/opt/bastion/bin/admin/osh-sync-watcher.sh +KillMode=process +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/lib/perl/OVH/Bastion.pm b/lib/perl/OVH/Bastion.pm new file mode 100644 index 0000000..8f9aa70 --- /dev/null +++ b/lib/perl/OVH/Bastion.pm @@ -0,0 +1,923 @@ +package OVH::Bastion; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Fcntl; +use POSIX qw(strftime); + +our $VERSION = '2.99.99-rc9.4'; + +BEGIN { + # only used by the handler below + my $_SAVED_ARGV = join('^', @ARGV); + + sub _warn_die_handler { + my ($type, $msg) = @_; + + # ignore if parsing code (undef) or in eval (1) + return 1 if (!defined $^S || $^S); + + # ignore this unimportant error (perl race condition?) + return 1 if (defined $msg and $msg =~ m{^panic: fold_constants JMPENV_PUSH returned 2}); + + # eval{} in a BEGIN{} in Net::DNS, ignore it + return 1 if (defined $msg and $msg =~ m{^Can't locate Net/}); + + my $criticity = ($type eq 'die' ? 'err' : 'warning'); + + # Net::Server can be noisy if the client fails to establish the SSL connection, + # transform thoses die into info to avoid triggering SIEM alerts + $criticity = 'info' if (defined $msg and $msg =~ m{^Could not finalize SSL connection with client handle}); + + require Carp; + OVH::Bastion::syslogFormatted( + criticity => $criticity, + type => $type, + fields => [['sudouser', $ENV{'SUDO_USER'}], ['version', $OVH::Bastion::VERSION], ['msg', $msg], ['program', $0], ['cmdline', $_SAVED_ARGV], ['trace', Carp::longmess()]] + ); + return 1; + } + + $SIG{__WARN__} = sub { _warn_die_handler("warn", @_) }; + $SIG{__DIE__} = sub { _warn_die_handler("die", @_) }; +} + +use JSON; +use Term::ANSIColor; + +use File::Basename; # dirname +use Cwd; # need to use realpath because we use that to build sudoers for groups +our $BASEPATH = Cwd::realpath(dirname(__FILE__) . '/../../../'); # usually /opt/bastion + +# untaint $BASEPATH manually because realpath() tainted it back +($BASEPATH) = $BASEPATH =~ m{(\S+)}; + +use lib dirname(__FILE__) . '/../'; +use OVH::Result; + +use parent qw( Exporter ); +our @EXPORT = ## no critic (AutomaticExportation) + qw( osh_header osh_footer osh_exit osh_debug osh_info osh_warn osh_crit osh_ok HEXIT warn_syslog ); + +our $AUTOLOAD; + +use constant { + EXIT_OK => 0, + EXIT_PLUGIN_ERROR => 100, + EXIT_ACCOUNT_INACTIVE => 101, + EXIT_HOST_NOT_FOUND => 102, + EXIT_READ_ONLY => 103, + EXIT_UNKNOWN_COMMAND => 104, + EXIT_EXEC_FAILED => 105, + EXIT_RESTRICTED_COMMAND => 106, + EXIT_ACCESS_DENIED => 107, + EXIT_PASSFILE_NOT_FOUND => 108, + EXIT_OUT_OF_SPACE => 109, + EXIT_CONFIGURATION_FAILURE => 110, + EXIT_GETOPTS_FAILED => 111, + EXIT_NO_HOST => 112, + EXIT_ACCOUNT_EXPIRED => 113, + EXIT_INTERACTIVE_DISABLED => 114, + EXIT_CONFLICTING_OPTIONS => 115, + EXIT_MOSH_DISABLED => 116, + EXIT_GOT_SIGNAL => 117, + EXIT_MAINTENANCE_MODE => 118, + EXIT_REALM_INVALID => 119, + EXIT_ACCOUNT_INVALID => 120, + EXIT_TTL_EXPIRED => 121, + EXIT_MFA_PASSWORD_SETUP_REQUIRED => 122, + EXIT_MFA_TOTP_SETUP_REQUIRED => 123, + EXIT_MFA_ANY_SETUP_REQUIRED => 124, + EXIT_MFA_FAILED => 125, + EXIT_TTYREC_CMDLINE_FAILED => 126, +}; + +use constant { + MFA_PASSWORD_REQUIRED_GROUP => 'mfa-password-reqd', + MFA_PASSWORD_CONFIGURED_GROUP => 'mfa-password-configd', + MFA_PASSWORD_BYPASS_GROUP => 'mfa-password-bypass', + MFA_TOTP_REQUIRED_GROUP => 'mfa-totp-reqd', + MFA_TOTP_CONFIGURED_GROUP => 'mfa-totp-configd', + MFA_TOTP_BYPASS_GROUP => 'mfa-totp-bypass', + PAM_AUTH_BYPASS_GROUP => 'bastion-nopam', + + TOTP_FILENAME => '.otp', + TOTP_BASEDIR => '/var/otp', + + OPT_ACCOUNT_INGRESS_PIV_POLICY => 'ingress_piv_policy', + OPT_ACCOUNT_INGRESS_PIV_GRACE => 'ingress_piv_grace', + OPT_ACCOUNT_ALWAYS_ACTIVE => 'always_active', + OPT_ACCOUNT_IDLE_IGNORE => 'idle_ignore', +}; + +########### +# FUNCTIONS + +# for i in *.inc ; do bz=$(basename $i .inc) ; echo "$bz => "'[qw{ '$(grep ^sub $i | grep -v 'sub _' | awk '{print $2}' | tr "\n" " ")'}],' ; done +my %_autoload_files = ( + allowdeny => [ + qw{ get_personal_account_keys get_group_keys is_access_way_granted get_ip ip2host get_user_groups print_acls is_access_granted ssh_test_access_way get_acl_way get_acls duration2human } + ], + allowkeeper => [ + qw{ is_user_in_group is_group_existing get_acl_from_file get_account_acl is_valid_uid get_next_available_uid is_bastion_account_valid_and_existing is_account_valid is_account_existing access_modify is_valid_group is_valid_group_and_existing add_user_to_group get_group_servers_list get_group_list get_account_list is_admin is_super_owner is_group_aclkeeper is_group_gatekeeper is_group_owner is_group_guest is_group_member is_auditor get_remote_accounts_from_realm is_valid_ttl get_realm_list } + ], + configuration => [qw{ load_configuration_file main_configuration_directory load_configuration config account_config plugin_config group_config json_load }], + execute => [qw{ execute result_from_helper helper_decapsulate helper }], + interactive => [qw{ interactive }], + log => [qw{ syslog syslog_close syslogFormatted warn_syslog log_access_insert log_access_update log_access_get }], + os => [ + qw{ sysinfo is_linux is_debian is_redhat is_bsd is_freebsd is_openbsd is_netbsd sys_useradd sys_groupadd sys_userdel sys_groupdel sys_addmembertogroup sys_delmemberfromgroup sys_changepassword sys_neutralizepassword sys_setpasswordpolicy sys_getsudoersfolder sys_getpasswordinfo sys_setfacl has_acls } + ], + ssh => [ + qw{ get_ssh_pub_key_info is_valid_public_key get_from_for_user_key generate_ssh_key get_bastion_ips get_supported_ssh_algorithms_list is_allowed_algo_and_size is_valid_fingerprint print_public_key get_authorized_keys_from_file account_ssh_config_get account_ssh_config_set verify_piv put_authorized_keys_to_file ssh_ingress_keys_piv_apply } + ], + password => [qw{ get_hashes_from_password get_hashes_list }], + jail => [qw{ jailify }], + mock => [qw{ enable_mocking is_mocking set_mock_data mock_get_account_entry mock_get_account_access_way }], +); + +sub AUTOLOAD { ## no critic (AutoLoading) + my $subname = $AUTOLOAD; + $subname =~ s/.*:://; + + foreach my $file (keys %_autoload_files) { + if (grep { $subname eq $_ } @{$_autoload_files{$file}}) { + require $BASEPATH . '/lib/perl/OVH/Bastion/' . $file . '.inc'; + + # Catch a declared but not implemented subroutine before calling it + if (not exists &$AUTOLOAD) { + die "AUTOLOAD FAILED: forgot to declare $subname in $file"; + } + + goto &$AUTOLOAD; + } + } + + die "AUTOLOAD FAILED: $AUTOLOAD"; +} + +# checks wether an account is expired (inactivity) if that's configured on this bastion +sub is_account_nonexpired { + my %params = @_; + my $sysaccount = $params{'sysaccount'}; + my $remoteaccount = $params{'remoteaccount'}; + + if (not $sysaccount) { + return R('ERR_MISSING_PARAMETER', msg => "Missing 'sysaccount' argument"); + } + + # accountMaxInactiveDays is the max allowed inactive days to not block login. 0 means feature disabled. + my $accountMaxInactiveDays = 0; + my $fnret = OVH::Bastion::config('accountMaxInactiveDays'); + if ($fnret and $fnret->value > 0) { + $accountMaxInactiveDays = $fnret->value; + } + + my $isFirstLogin; + my $lastlog; + my $filepath = "/home/$sysaccount/lastlog" . ($remoteaccount ? "_$remoteaccount" : ""); + my $value = {filepath => $filepath}; + if (-e $filepath) { + $isFirstLogin = 0; + $lastlog = (stat(_))[9]; + osh_debug("is_account_nonexpired: got lastlog date: $lastlog"); + + # if lastlog file is available, fetch some info from it + if (open(my $lastloginfh, "<", $filepath)) { + my $info = <$lastloginfh>; + chomp $info; + close($lastloginfh); + $value->{'info'} = $info; + } + } + else { + my ($previousDir) = getcwd() =~ m{^(/[a-z0-9_./-]+)}i; + if (!chdir("/home/$sysaccount")) { + osh_debug("is_account_nonexpired: no exec access to the folder!"); + return R('ERR_NO_ACCESS', msg => "No read access to this account folder to compute last login time"); + } + chdir($previousDir); + $isFirstLogin = 1; + + # get the account creation timestamp as the lastlog + $fnret = OVH::Bastion::account_config(account => $sysaccount, key => "creation_timestamp"); + if ($fnret && $fnret->value) { + $lastlog = $fnret->value; + osh_debug("is_account_nonexpired: got creation date from config.creation_timestamp: $lastlog"); + } + elsif (-e "/home/$sysaccount/accountCreate.comment") { + + # fall back to the stat of the accountCreate.comment file + $lastlog = (stat(_))[9]; + osh_debug("is_account_nonexpired: got creation date from accountCreate.comment stat: $lastlog"); + } + else { + # last fall back to the stat of the ttyrec/ folder + $lastlog = (stat("/home/$sysaccount/ttyrec"))[9]; + osh_debug("is_account_nonexpired: got creation date from ttyrec/ stat: $lastlog"); + } + } + + my $seconds = time() - $lastlog; + my $days = int($seconds / 86400); + $value->{'days'} = $days; + $value->{'seconds'} = $seconds; + $value->{'already_seen_before'} = !$isFirstLogin; + osh_debug("Last account activity: $days days ago"); + + if ($accountMaxInactiveDays == 0) { + + # no expiration configured, allow login and return some info + return R('OK_FIRST_LOGIN', value => $value) if $isFirstLogin; + return R('OK_EXPIRATION_NOT_CONFIGURED', value => $value); + } + else { + if ($days < $accountMaxInactiveDays) { + + # expiration configured, but account not expired, allow login + return R('OK_NOT_EXPIRED', value => $value); + } + else { + # account expired, deny login + my $msg = OVH::Bastion::config("accountExpiredMessage")->value; + $msg = "Sorry, but your account has expired (#DAYS# days), access denied by policy." if !$msg; + $msg =~ s/#DAYS#/$days/g; + return R( + 'KO_EXPIRED', + value => $value, + msg => $msg, + ); + } + } + return R('ERR_INTERNAL_ERROR'); +} + +# Check whether a user is still active, if this feature has been enabled in the config +sub is_account_active { + my %params = @_; + my $account = $params{'account'}; + my $fnret; + + my $checkProgram = OVH::Bastion::config('accountExternalValidationProgram')->value; + return R('OK_FEATURE_DISABLED') if !$checkProgram; + + # Get sysaccount from account because for realm case we need to check if the support account of the realm is active + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + my $sysaccount = $fnret->value->{'sysaccount'}; + + # If in alwaysActive, then is active + my $alwaysActiveAccounts = OVH::Bastion::config('alwaysActiveAccounts'); + if ($alwaysActiveAccounts and $alwaysActiveAccounts->value) { + if (grep { $sysaccount eq $_ } @{$alwaysActiveAccounts->value}) { + return R('OK'); + } + } + + # If account has the flag in public config, then is active + if (OVH::Bastion::account_config(account => $sysaccount, key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, public => 1)) { + return R('OK'); + } + + if (!-r -x $checkProgram) { + warn_syslog("Configured check program '$checkProgram' doesn't exist or is not readable+executable"); + return R('ERR_INTERNAL', msg => "The account activeness check program doesn't exist. Report this to sysadmin!"); + } + + $fnret = OVH::Bastion::execute(cmd => [$checkProgram, $sysaccount]); + if (!$fnret) { + warn_syslog("Failed to execute program '$checkProgram': " . $fnret->msg); + return R('ERR_INTERNAL', msg => "The account activeness check program failed. Report this to sysadmin!"); + } + +=cut exit code meanings are as follows: + EXIT_ACTIVE => 0, + EXIT_INACTIVE => 1, + EXIT_UNKNOWN => 2, + EXIT_UNKNOWN_SILENT_ERROR => 3, + EXIT_UNKNOWN_NOISY_ERROR => 4, +=cut + + if ($fnret->value->{'status'} == 0) { + return R('OK'); + } + if ($fnret->value->{'status'} == 3) { + if (!$fnret->value->{'stderr'}) { + warn_syslog("External account validation program returned status 2 (empty stderr)"); + } + else { + warn_syslog("External account validation program returned status 2: " . $_) for @{$fnret->value->{'stderr'} || []}; + } + } + if ($fnret->value->{'status'} == 4) { + if (!$fnret->value->{'stderr'}) { + osh_warn("External account validation program returned status 2 (empty stderr)"); + } + else { + osh_warn("External account validation program returned status 2: " . $_) for @{$fnret->value->{'stderr'} || []}; + } + } + if ($fnret->value->{'status'} >= 2 && $fnret->value->{'status'} <= 4) { + return R('ERR_UNKNOWN'); + } + + return R('KO_INACTIVE_ACCOUNT'); +} + +sub json_output { ## no critic (ArgUnpacking) + my $R = shift; + my %params = @_; + my $force_default = $params{'force_default'}; + my $command = $params{'command'} || $ENV{'PLUGIN_NAME'}; + + my $JsonObject = JSON->new->utf8; + $JsonObject = $JsonObject->convert_blessed(1); + if ($ENV{'PLUGIN_JSON'} eq 'PRETTY' and not $force_default) { + $JsonObject->pretty(1); + } + my $encoded_json = $JsonObject->encode({error_code => $R->err, error_message => $R->msg, command => $command, value => $R->value}); + + # rename forbidden strings + $encoded_json =~ s/JSON_(START|OUTPUT|END)/JSON__$1/g; + + if ($ENV{'PLUGIN_JSON'} eq 'GREP' and not $force_default) { + $encoded_json =~ tr/\r\n/ /; + print "\nJSON_OUTPUT=$encoded_json\n"; + } + else { + print "\nJSON_START\n$encoded_json\nJSON_END\n"; + } + return; +} + +sub osh_header { + my $text = shift || ''; + + require Sys::Hostname; + my $hostname = Sys::Hostname::hostname(); + my $versionline = 'the-bastion-' . $VERSION; + my $output = ''; + $output .= colored('---' . $hostname . '-' x (80 - length($hostname) - length($versionline) - 6) . "$versionline---" . "\n", 'bold blue'); + $output .= colored("=> $text\n", "blue"); + $output .= colored('-' x 80 . "\n", 'blue'); + + print $output unless ($ENV{'PLUGIN_QUIET'}); + return; +} + +sub osh_footer { + my $text = shift; + if (not defined $text) { + $text = $ENV{'PLUGIN_NAME'}; + } + + my $output = ''; + $output .= colored('-' x (80 - length($text) - 6) . "---" . "\n", 'bold blue'); + + print $output unless ($ENV{'PLUGIN_QUIET'}); + return; +} + +# Used to exit plugins. Can be used in several ways: +# With an R object: osh_exit(R('OK', value => {}, msg => "okey")) +# Or with 1 value, that will be taken as the R->err: osh_exit('OK') +# Or with 2 values, that will be taken as err, msg: osh_exit('ERR_UNKNOWN', 'Unexpected error') +# With more values, they'll be used as constructor for an R object +sub osh_exit { ## no critic (ArgUnpacking) + my $R; + if (@_ == 1) { + $R = ref $_[0] eq 'OVH::Result' ? $_[0] : R($_[0]); + } + elsif (@_ == 2) { + my $err = shift || 'OK'; + my $msg = shift; + $R = R($err, msg => $msg); + } + else { + $R = R(@_); + } + + if (!$R) { + OVH::Bastion::osh_crit($R->msg); + } + elsif ($R->msg ne $R->err) { + OVH::Bastion::osh_info($R->msg); + } + + if ($ENV{'PLUGIN_JSON'}) { + OVH::Bastion::json_output($R); + } + osh_footer(); + + exit($R ? OVH::Bastion::EXIT_OK : OVH::Bastion::EXIT_PLUGIN_ERROR); +} + +sub osh_ok { ## no critic (ArgUnpacking) + my $R = ref $_[0] eq 'OVH::Result' ? $_[0] : R('OK', value => $_[0], msg => $_[1]); + + if ($R->msg ne $R->err) { + OVH::Bastion::osh_info($R->msg); + } + + if ($ENV{'PLUGIN_JSON'}) { + OVH::Bastion::json_output($R); + } + osh_footer(); + exit OVH::Bastion::EXIT_OK; +} + +# HEXIT aka "helper exit", used by helper scripts found in helpers/ +# Can be used in several ways: +# With an R object: HEXIT(R('OK', value => {}, msg => "okey")) +# Or with 1 value, that will be taken as the R->err: HEXIT('OK') +# Or with 2 values, that will be taken as err, msg: HEXIT('ERR_UNKNOWN', 'Unexpected error') +# With more values, they'll be used as constructor for an R object +sub HEXIT { ## no critic (ArgUnpacking) + my $R; + + if (@_ == 1) { + $R = ref $_[0] eq 'OVH::Result' ? $_[0] : R($_[0]); + } + elsif (@_ == 2) { + my $err = shift || 'OK'; + my $msg = shift; + $R = R($err, msg => $msg); + } + else { + $R = R(@_); + } + OVH::Bastion::json_output($R, force_default => 1); + exit 0; +} + +sub osh_debug { + my $text = shift; + if (($ENV{'PLUGIN_DEBUG'} or $ENV{'OSH_DEBUG'}) and not $ENV{'PLUGIN_QUIET'}) { + foreach my $line (split /^/, $text) { + chomp $line; + print STDERR colored("~ <$$:$0> $line", 'bold black') . "\n"; + } + } + return; +} + +sub osh_info { + return _osh_log(text => shift, color => 'blue', onlyPrefix => 1); +} + +sub osh_warn { + return _osh_log(text => shift, color => 'magenta'); +} + +sub osh_crit { + return _osh_log(text => shift, color => 'red bold'); +} + +sub _osh_log { + my %params = @_; + + my $output = $ENV{'FORCE_STDERR'} ? *STDERR : *STDOUT; + if ($ENV{'PLUGIN_QUIET'}) { + print $output $params{'text'} . "\n"; + } + else { + foreach my $line (split /^/, $params{'text'}) { + chomp $line; + + if ($params{'onlyPrefix'}) { + print $output colored('~ ', $params{'color'}) . "$line\n"; + } + else { + print $output colored("~ $line", $params{'color'}) . "\n"; + } + } + } + return; +} + +sub is_valid_ip { + my %params = @_; + my $ip = $params{'ip'}; + my $allowPrefixes = $params{'allowPrefixes'}; # if not, a /24 or /32 notation is rejected + my $fast = $params{'fast'}; # fast mode: avoid instanciating Net::IP... except if ipv6 + + if ($fast and $ip !~ m{:}) { + + # fast asked and it's not an IPv6, regex ftw + if ($ip =~ m{^(?(?[0-9]{1,3})\.(?[0-9]{1,3})\.(?[0-9]{1,3})\.(?[0-9]{1,3}))((?/)(?\d+))?$}) { ## no critic (ProhibitUnusedCapture) + if (defined $+{'prefix'} and not $allowPrefixes) { + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip), as prefixes are not allowed"); + } + foreach my $key (qw{ x1 x2 x3 x4 }) { + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)") if (not defined $+{$key} or $+{$key} > 255); + } + if (defined $+{'prefix'} and $+{'prefix'} > 32) { + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)"); + } + if (defined $+{'slash'} and not defined $+{'prefix'}) { + + # got a / in $ip but it's not followed by \d+ + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)"); + } + return R('OK', value => {ip => $ip}) if (defined $+{'prefix'} && $+{'prefix'} != 32); + return R('OK', value => {ip => $+{'shortip'}}); + } + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)"); + } + + require Net::IP; + my $IpObject = Net::IP->new($ip); + + if (not $IpObject) { + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)"); + } + + my $shortip = $IpObject->prefix; + + # if /32 or /128, omit the /prefixlen on $shortip + my $type = 'prefix'; + if ( ($IpObject->version == 4 and $IpObject->prefixlen == 32) + or ($IpObject->version == 6 and $IpObject->prefixlen == 128)) + { + $shortip =~ s'/\d+$''; + $type = 'single'; + } + + if (not $allowPrefixes and $type eq 'prefix') { + return R('KO_INVALID_IP', msg => "Invalid IP address ($ip), as prefixes are not allowed"); + } + + return R('OK', value => {ip => $shortip, prefix => $IpObject->prefix, prefixlen => $IpObject->prefixlen, version => $IpObject->version, type => $type}); +} + +sub is_valid_port { + my %params = @_; + my $port = $params{'port'}; + if ($port =~ /^(\d+)$/ && $port > 0 && $port <= 65535) { + return R('OK', value => $1); + } + return R('ERR_INVALID_PARAMETER', msg => "Port must be numeric and 0 < port <= 65535"); +} + +sub is_valid_remote_user { + my %params = @_; + my $user = $params{'user'}; + if ($user =~ /^([a-zA-Z0-9._!-]{1,32})$/) { + return R('OK', value => $1); + } + return R('ERR_INVALID_PARAMETER', msg => "Specified user doesn't seem to be valid"); +} + +sub touch_file { + my $file = shift; + my $perms = shift; + + my $ret; + my $fh; + if (defined $perms) { + $ret = sysopen($fh, $file, O_RDWR | O_CREAT, $perms); + } + else { + $ret = sysopen($fh, $file, O_RDWR | O_CREAT); + } + + if ($ret) { + close($fh); + utime(undef, undef, $file); # update mod/access time to now + # just in case we didn't create the file, and $perms is specified, chmod the file + chmod $perms, $file if $perms; + return R('OK'); + } + + # else + return R('KO', msg => "Couldn't create file $file: $!"); +} + +sub get_plugin_list { + my %params = @_; + my $restrictedOnly = $params{'restrictedOnly'}; + + my %plugins; + foreach my $dir ( + $OVH::Bastion::BASEPATH . '/bin/plugin/open', + $OVH::Bastion::BASEPATH . '/bin/plugin/group-gatekeeper', + $OVH::Bastion::BASEPATH . '/bin/plugin/group-aclkeeper', + $OVH::Bastion::BASEPATH . '/bin/plugin/group-owner', + $OVH::Bastion::BASEPATH . '/bin/plugin/restricted', + $OVH::Bastion::BASEPATH . '/bin/plugin/admin', + ) + { + if (opendir(my $dh, $dir)) { + while (my $file = readdir($dh)) { + + # if exists, will be overwritten, that's why the order of foreach(dir) is important, + # from most open to most restricted (but plugins should never have the same name anyway) + $plugins{$file} = {name => $file, dir => $dir} if ($file !~ /\./); + } + close($dh); + } + } + if ($restrictedOnly) { + foreach my $plugin (keys %plugins) { + delete $plugins{$plugin} if $plugins{$plugin}->{'dir'} !~ m{/restricted$}; + } + } + return R('OK', value => \%plugins); +} + +sub can_account_execute_plugin { + my %params = @_; + my $account = $params{'account'} || OVH::Bastion::get_user_from_env()->value; + my $plugin = $params{'plugin'}; + my $fnret; + + if (not $plugin or not $account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory param account or plugin"); + } + + # sanitize for -T + my ($sanePlugin) = $plugin =~ /^([a-zA-Z0-9_-]+)$/; + if ($plugin ne $sanePlugin) { + return R('ERR_INVALID_PARAMETER', msg => "Parameter 'plugin' contains invalid characters"); + } + $plugin = $sanePlugin; + + my $path_plugin = $OVH::Bastion::BASEPATH . '/bin/plugin'; + + # first, check if the plugin is readonly-proof if we are in readonly mode (slave) + $fnret = OVH::Bastion::config('readOnlySlaveMode'); + $fnret or return $fnret; + if ($fnret->value and not OVH::Bastion::is_plugin_readonly_proof(plugin => $plugin)) { + return R('ERR_READ_ONLY', + msg => "You can't use this command on this bastion instance, as this is a write/modify command,\n" + . "and this bastion instance is read-only (slave). Please do this on the master instance of my cluster instead!"); + } + + # realm accounts are very restricted + if ($account =~ m{^realm_}) { + return R('ERR_SECURITY_VIOLATION', msg => "Realm support accounts can't execute any plugin by themselves"); + } + if ($account =~ m{/} && !grep { $plugin eq $_ } qw{ alive help info mtr nc ping selfForgetHostKey selfListAccesses selfListEgressKeys }) { + return R('ERR_REALM_USER', msg => "Realm accounts can't execute this plugin, use --osh help to get the allowed plugin list"); + } + + # open plugins, always start to look there + if (-f ($path_plugin . '/open/' . $plugin)) { + return R('OK', value => {fullpath => $path_plugin . '/open/' . $plugin, type => 'open', plugin => $plugin}); + } + + # aclkeeper/gatekeepers/owners plugins + if (-f ($path_plugin . '/group-aclkeeper/' . $plugin) or -f ($path_plugin . '/group-gatekeeper/' . $plugin) or -f ($path_plugin . '/group-owner/' . $plugin)) { + + # need to parse group to see if maybe member of group-gatekeeper or group-owner (or super owner) + my %canDo = (gatekeeper => 0, aclkeeper => 0, owner => 0); + + $fnret = OVH::Bastion::get_user_groups(extra => 1, account => $account); + my @userGroups = $fnret ? @{$fnret->value} : (); + + foreach my $type (qw{ aclkeeper gatekeeper owner }) { + if (-f "$path_plugin/group-$type/$plugin") { + + # we can always execute these commands if we are a super owner + my $canDo = OVH::Bastion::is_super_owner(account => $account) ? 1 : 0; + + # or if we are $type on at least one group + $canDo += grep { /^key.*-\Q$type\E$/ } @userGroups; + return R( + 'OK', + value => { + fullpath => "$path_plugin/group-$type/$plugin", + type => "group-$type", + plugin => $plugin + } + ) if $canDo; + return R( + 'KO_PERMISSION_DENIED', + value => {type => "group-type", plugin => $plugin}, + msg => "Sorry, you must be a group $type to use this command" + ); + } + } + + # unreachable code: + return R('KO_PERMISSION_DENIED', value => {type => 'group-unknown', plugin => $plugin}, msg => "Permission denied"); + } + + # restricted plugins (osh-* system groups based) + if (-f ($path_plugin . '/restricted/' . $plugin)) { + if (OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin")) { + return R('OK', value => {fullpath => $path_plugin . '/restricted/' . $plugin, type => 'restricted', plugin => $plugin}); + } + else { + return R( + 'KO_PERMISSION_DENIED', + value => {type => 'restricted', plugin => $plugin}, + msg => "Sorry, this command is restricted and requires you to be specifically granted" + ); + } + } + + # admin plugins + if (-f ($path_plugin . '/admin/' . $plugin)) { + if (OVH::Bastion::is_admin(account => $account)) { + return R('OK', value => {fullpath => $path_plugin . '/admin/' . $plugin, type => 'admin', plugin => $plugin}); + } + else { + return R( + 'KO_PERMISSION_DENIED', + value => {type => 'admin', plugin => $plugin}, + msg => "Sorry, this command is only available to bastion admins" + ); + } + } + + # still here ? sorry. + return R('KO_UNKNOWN_PLUGIN', value => {type => 'open'}, msg => "Unknown command"); +} + +sub is_plugin_readonly_proof { + my %params = @_; + my $plugin = $params{'plugin'}; + if (not defined $plugin) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'plugin'"); + } + my $fnret = OVH::Bastion::plugin_config(plugin => $plugin, key => "master_only"); + if ($fnret && $fnret->value) { + return R('KO_NOT_READONLY', msg => "Plugin not allowed in readonly mode"); + } + + # if not "1" or not defined, default to allow on master or slaves + return R('OK'); +} + +sub set_terminal_mode_for_plugin { + my %params = @_; + my $plugin = $params{'plugin'}; + my $action = $params{'action'}; + + if (my @missingParameters = grep { not defined $params{$_} } qw{ plugin action }) { + local $" = ', '; + return R('ERR_MISSING_PARAMETER', "Missing mandatory parameter(s): @missingParameters"); + } + if (not grep { $action eq $_ } qw{ set restore }) { + return R('ERR_INVALID_PARAMETER', "Parameter 'action' is invalid, expected either 'set' or 'restore'"); + } + + my $mode; + my $fnret = OVH::Bastion::plugin_config(plugin => $plugin, key => "terminal_mode"); + if ($fnret && defined $fnret->value) { + if (grep { $fnret->value eq $_ } qw{ noecho cbreak raw }) { + $mode = $fnret->value; + } + else { + osh_warn("Invalid terminal configuration setup for plugin $plugin, please report to your sysadmin!"); + } + } + + # noecho: user might type passwords there + # cbreak: only allow CTRL+C + # raw: block CTRL+C + + return R('OK_NOT_NEEDED') if not defined $mode; + + $mode = 'restore' if $action eq 'restore'; + + require Term::ReadKey; + Term::ReadKey::ReadMode($mode); + return R('OK'); # ReadMode returns nothing :( +} + +sub generate_uniq_id { + require Digest::SHA; + return R('OK', value => unpack("H12", Digest::SHA::sha512(pack("SLL", $$, time, int(rand(2**32)))))); +} + +sub get_user_from_env { + my ($sanitized) = (getpwuid($>))[0] =~ /([0-9a-zA-Z_.-]+)/; + return R('OK', value => $sanitized); +} + +sub get_home_from_env { + my ($sanitized) = (getpwuid($>))[7] =~ m{^([a-zA-Z0-9_/.-]+)$}; + $sanitized =~ s/\.+/./g; # disallow 2 or more consecutive dots, i.e. "john.doe" is ok, "john/../../../etc/passwd" is not + return R('OK', value => $sanitized); +} + +sub get_passfile { + my %params = @_; + my $nameHint = $params{'hint'}; + my $context = $params{'context'}; + my $tryLegacy = $params{'tryLegacy'}; + my $self = $params{'self'} || OVH::Bastion::get_user_from_env()->value; + + $nameHint =~ s/[^a-zA-Z0-9_.-]//g; + + if ($context eq 'self') { + + # in this case, we look into the $self home dir + my $home = OVH::Bastion::get_home_from_env()->value; + my $passFile = "$home/pass/$self"; + return R('OK', value => $passFile) if (-f -r $passFile); + } + elsif ($context eq 'group') { + + # new mode: nameHint is actually the name of a group (technically, shortGroup) + my $passFile = "/home/key$nameHint/pass/$nameHint"; + return R('OK', value => $passFile) if (-f -r $passFile); + + if ($tryLegacy) { + + # auto fall back to legacy mode: nameHint is a file under the global /home/passkeeper directory + $passFile = "/home/passkeeper/$nameHint"; + return R('OK', value => $passFile) if (-f -r $passFile); + } + } + elsif ($context eq 'legacy') { + + # legacy mode only: nameHint is a file under the global /home/passkeeper directory + my $passFile = "/home/passkeeper/$nameHint"; + return R('OK', value => $passFile) if (-f -r $passFile); + } + return R('KO_PASSFILE_NOT_FOUND', msg => "Unable to find (or read) a password file in context '$context' and name '$nameHint'"); +} + +sub build_ttyrec_cmdline { + my %params = @_; + + if (!$params{'home'}) { + return R('ERR_MISSING_PARAMETER', msg => "Missing home parameter"); + } + if (!$params{'ip'}) { + return R('ERR_MISSING_PARAMETER', msg => "Missing ip parameter"); + } + + # build ttyrec filename format + my $bastionName = OVH::Bastion::config('bastionName')->value; + my $ttyrecFilenameFormat = OVH::Bastion::config('ttyrecFilenameFormat')->value; + $ttyrecFilenameFormat =~ s/&bastionname/$bastionName/g; + $ttyrecFilenameFormat =~ s/&uniqid/$params{'uniqid'}/g if $params{'uniqid'}; + $ttyrecFilenameFormat =~ s/&ip/$params{'ip'}/g if $params{'ip'}; + $ttyrecFilenameFormat =~ s/&port/$params{'port'}/g if $params{'port'}; + $ttyrecFilenameFormat =~ s/&user/$params{'user'}/g if $params{'user'}; + $ttyrecFilenameFormat =~ s/&account/$params{'account'}/g if $params{'account'}; + + if ($ttyrecFilenameFormat =~ /&(bastionname|uniqid|ip|port|user|account)/) { + + # if we still have a placeholder here, then we were missing parameters + return R('ERR_MISSING_PARAMETER', msg => "Missing bastionname, uniqid, ip, port, user or account in ttyrec cmdline building"); + } + + # ensure there are no '/' + $ttyrecFilenameFormat =~ tr{/}{_}; + + # preprend (and create) directory + my $saveDir = $params{'home'} . "/ttyrec"; + mkdir($saveDir); + if ($params{'realm'} && $params{'remoteaccount'}) { + $saveDir .= "/" . $params{'remoteaccount'}; + mkdir($saveDir); + } + $saveDir .= "/" . $params{'ip'}; + mkdir($saveDir); + + my $saveFileFormat = "$saveDir/$ttyrecFilenameFormat"; + + # also build the first ttyrec filename ourselves + my $saveFile = $saveFileFormat; + $saveFile = strftime($saveFile, localtime(time)); + if ($saveFile =~ /#usec#/) { + require Time::HiRes; + my $usec = sprintf("%06d", (Time::HiRes::gettimeofday())[1]); + $saveFile =~ s{#usec#}{$usec}g; + } + + # forge ttyrec command + my $idleKillTimeout = OVH::Bastion::config('idleKillTimeout')->value; + my $idleLockTimeout = OVH::Bastion::config('idleLockTimeout')->value; + my $warnBeforeLockSeconds = OVH::Bastion::config('warnBeforeLockSeconds')->value; + my $warnBeforeKillSeconds = OVH::Bastion::config('warnBeforeKillSeconds')->value; + + my @ttyrec = ('ttyrec', '-f', $saveFile, '-F', $saveFileFormat); + push @ttyrec, '-v' if $params{'debug'}; + push @ttyrec, '-T', 'always' if $params{'tty'}; + push @ttyrec, '-T', 'never' if $params{'notty'}; + + my $fnret = OVH::Bastion::account_config(account => $params{'account'}, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1); + if ($fnret && $fnret->value =~ /yes/) { + osh_debug("Account is immune to idle, not adding ttyrec commandline parameters"); + } + else { + push @ttyrec, '-k', $idleKillTimeout if $idleKillTimeout; + push @ttyrec, '-t', $idleLockTimeout if $idleLockTimeout; + push @ttyrec, '-s', "To unlock, use '--osh unlock' from another console" if $idleLockTimeout; + push @ttyrec, '--warn-before-lock', $warnBeforeLockSeconds if $warnBeforeLockSeconds; + push @ttyrec, '--warn-before-kill', $warnBeforeKillSeconds if $warnBeforeKillSeconds; + } + + my $ttyrecAdditionalParameters = OVH::Bastion::config('ttyrecAdditionalParameters')->value; + push @ttyrec, @$ttyrecAdditionalParameters if @$ttyrecAdditionalParameters; + + return R('OK', value => {saveFile => $saveFile, cmd => \@ttyrec}); +} + +1; diff --git a/lib/perl/OVH/Bastion/Plugin.pm b/lib/perl/OVH/Bastion/Plugin.pm new file mode 100644 index 0000000..b55bc9f --- /dev/null +++ b/lib/perl/OVH/Bastion/Plugin.pm @@ -0,0 +1,144 @@ +package OVH::Bastion::Plugin; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use Getopt::Long (); + +use File::Basename; +use lib dirname(__FILE__) . '/../../../../lib/perl'; +use OVH::Bastion; +use OVH::Result; + +$| = 1; + +use Exporter 'import'; +our ($user, $ip, $host, $port, $scriptName, $self, $sysself, $realm, $remoteself, $HOME, $savedArgs); ## no critic (ProhibitPackageVars) +our @EXPORT = qw( $user $ip $host $port $scriptName $self $sysself $realm $remoteself $HOME $savedArgs ); ## no critic (ProhibitAutomaticExportation) +our @EXPORT_OK = qw( help ); + +my $_helptext; +sub help { osh_info $_helptext; return 1; } + +sub begin { + my %params = @_; + + my $options = $params{'options'}; + my $header = $params{'header'}; + my $argv = $params{'argv'}; + my $helpfunc = $params{'help'}; + $_helptext = $params{'helptext'}; + + my $fnret; + my @pluginOptions; + ($user, $ip, $host, $port, @pluginOptions) = @$argv; + + $helpfunc = \&help if (ref $helpfunc ne 'CODE'); + + # validate user, ip, port when specified, undef them otherwise (instead of '') + + if (defined $user && $user ne '') { + $fnret = OVH::Bastion::is_valid_remote_user(user => $user); + $fnret or osh_exit $fnret; + $user = $fnret->value; + } + else { + undef $user; + } + + if (defined $ip && $ip ne '') { + $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); + $fnret or osh_exit $fnret; + $ip = $fnret->value->{'ip'}; + } + else { + # special case due to osh.pl: when host=1.2.3.0/24 then ip='' + # in that case, validate host and set ip to the same + if ($host =~ m{/}) { + $fnret = OVH::Bastion::is_valid_ip(ip => $host, allowPrefixes => 1); + $fnret or osh_exit $fnret; + $ip = $host = $fnret->value->{'ip'}; + } + else { + undef $ip; + } + } + + if (defined $port && $port ne '') { + $fnret = OVH::Bastion::is_valid_port(port => $port); + $fnret or osh_exit $fnret; + $port = $fnret->value; + } + + undef $host if $host eq ''; + + # + # Options from extraArgs + # + + $savedArgs = join('^', @pluginOptions); + my ($result, @optwarns); + if (ref $options eq 'HASH' and %$options) { + eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = Getopt::Long::GetOptionsFromArray(\@pluginOptions, %$options); + }; + if ($@) { die $@ } + } + else { + $result = 1; + } + + # + # get scriptName, set a safe PATH env, and some other ENV vars + # + + ($scriptName) = $0 =~ m{([a-zA-Z0-9]+)(\.[a-zA-Z0-9]+)*$}; + $_helptext =~ s/SCRIPT_NAME/$scriptName/g if $_helptext; + $ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin'; + $ENV{'PLUGIN_NAME'} = $scriptName; + + osh_header($header) if $header; + + if (!$result) { + $helpfunc->(); + local $" = ", "; + osh_exit 'ERR_BAD_OPTIONS', "Error parsing options: @optwarns"; + } + + if ($ENV{'PLUGIN_HELP'}) { + $helpfunc->(); + osh_exit; + } + + $self = OVH::Bastion::get_user_from_env()->value; + $HOME = OVH::Bastion::get_home_from_env()->value; + + $fnret = OVH::Result::R('OK', value => {sysaccount => $self, account => $self, realm => undef, remoteaccount => undef}); + if ($< == 0) { + ; # called by root, don't verify if it's a bastion account (because it's not) + } + elsif ($self =~ /^realm_([a-zA-Z0-9_.-]+)/) { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => "$1/" . $ENV{'LC_BASTION'}); + $fnret or osh_exit('ERR_INVALID_ACCOUNT', "The realm-scoped account is invalid (" . $fnret->msg . ")"); + } + else { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $self); + $fnret or osh_exit('ERR_INVALID_ACCOUNT', "The account is invalid (" . $fnret->msg . ")"); + } + $sysself = $fnret->value->{'sysaccount'}; + $self = $fnret->value->{'account'}; + $realm = $fnret->value->{'realm'}; + $remoteself = $fnret->value->{'remoteaccount'}; + + if (not(-d -r $HOME)) { + osh_exit 'ERR_MISSING_HOME', "Error with your HOME directory ($HOME), please report to your sysadmin."; + } + if ($sysself ne $ENV{'USER'}) { + osh_exit 'ERR_INVALID_USER', "Error with your USER (\"$sysself\" vs \"$ENV{'USER'}\"), please report to your sysadmin."; + } + + # only unparsed options are remaining there + return \@pluginOptions; +} + +1; diff --git a/lib/perl/OVH/Bastion/Plugin/generatePassword.pm b/lib/perl/OVH/Bastion/Plugin/generatePassword.pm new file mode 100644 index 0000000..638247c --- /dev/null +++ b/lib/perl/OVH/Bastion/Plugin/generatePassword.pm @@ -0,0 +1,195 @@ +package OVH::Bastion::Plugin::generatePassword; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; + +sub preconditions { + my %params = @_; + my $self = $params{'self'}; + my $sudo = $params{'sudo'}; + my $group = $params{'group'}; + my $account = $params{'account'}; + my $size = $params{'size'}; + my $context = $params{'context'}; + + my $fnret; + my ($shortGroup, $passhome, $base); + + if (!$size || !$context) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'size' or 'context'"); + } + + if ($context eq 'group') { + if (not $group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'group'"); + } + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or return $fnret; + $group = $fnret->value->{'group'}; + $shortGroup = $fnret->value->{'shortGroup'}; + $passhome = "/home/$group/pass"; + $base = "$passhome/$shortGroup"; + } + elsif ($context eq 'account') { + if (not $account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'account'"); + } + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; + $passhome = "/home/$account/pass"; + $base = "$passhome/$account"; + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Expected a context 'group' or 'account'"); + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $self); + $fnret or return $fnret; + $self = $fnret->value->{'account'}; + + return R('ERR_INVALID_PARAMETER', msg => "The argument 'size' must be an integer") if $size !~ /^\d+$/; + return R('ERR_INVALID_PARAMETER', msg => "Specified size must be >= 8") if $size < 8; + return R('ERR_INVALID_PARAMETER', msg => "Specified size must be <= 127") if $size > 128; + + if ($context eq 'account' && $self ne $account) { + $fnret = OVH::Bastion::is_user_in_group(user => $self, group => "osh-accountGeneratePassword"); + $fnret or HEXIT('ERR_SECURITY_VIOLATION', msg => "You're not allowed to run this, dear $self"); + } + elsif ($context eq 'group') { + $fnret = OVH::Bastion::is_group_owner(account => $self, group => $shortGroup, superowner => 1, sudo => $sudo); + $fnret or HEXIT('ERR_NOT_ALLOWED', msg => "You're not a group owner of $shortGroup, dear $self"); + } + + # return untainted values + return R( + 'OK', + value => { + self => $self, + account => $account, + shortGroup => $shortGroup, + group => $group, + size => $size, + context => $context, + passhome => $passhome, + base => $base + } + ); +} + +sub act { + my %params = @_; + my $fnret = preconditions(%params); + $fnret or return $fnret; + + my %values = %{$fnret->value()}; + my ($self, $account, $shortGroup, $group, $size, $passhome, $base, $context, $passhome, $base) = + @values{qw{ self account shortGroup group size passhome base context passhome base }}; + + my $pass; + my $antiloop = 1000; + + my $hashes; + RETRY: while ($antiloop-- > 0) { + + # generate a password + $pass = ''; + foreach (1 .. $size) { + $pass .= chr(int(rand(ord('~') - ord('!')) + ord('!'))); + } + + # get the corresponding hashes + $fnret = OVH::Bastion::get_hashes_from_password(password => $pass); + $fnret or HEXIT($fnret); + + # verify that the hashes match this regex (some constructors need it) + my $check_re = qr'^\$\d\$[a-zA-Z0-9]+\$[a-zA-Z0-9.\/]+$'; + foreach my $hash (keys %{$fnret->value}) { + next RETRY if ($fnret->value->{$hash} && $fnret->value->{$hash} !~ $check_re); + } + + $hashes = $fnret->value; + last; + } + + if (ref $hashes ne 'HASH') { + return R('ERR_INTERNAL', msg => "Couldn't generate a valid password"); + } + + # push password in a file + if (!-d $passhome) { + if (!mkdir $passhome) { + HEXIT('ERR_INTERNAL', msg => "Couldn't create passwords directory in group home '$passhome' ($!)"); + } + if ($context eq 'account') { + if (my (undef, undef, $uid, $gid) = getpwnam($account)) { + chown $uid, $gid, $passhome; + } + } + } + if (!-d $passhome) { + HEXIT('ERR_INTERNAL', msg => "Couldn't create passwords directory in group home"); + } + chmod 0750, $passhome; + if (-e $base) { + + # rotate old passwords + unlink "$base.99"; + foreach my $i (1 .. 98) { + my $n = 99 - $i; + my $next = $n + 1; + if (-e "$base.$n") { + osh_debug "renaming $base.$n to $base.$next"; + if (!rename "$base.$n", "$base.$next") { + HEXIT('ERR_INTERNAL', msg => "Coudn't rename '$base.$n' to '$base.$next' ($!)"); + } + if (-e "$base.$n.metadata" && !rename "$base.$n.metadata", "$base.$next.metadata") { + HEXIT('ERR_INTERNAL', msg => "Coudn't rename '$base.$n.metadata' to '$base.$next.metadata' ($!)"); + } + } + } + osh_debug "renaming $base to $base.1"; + if (!rename "$base", "$base.1") { + HEXIT('ERR_INTERNAL', msg => "Coudn't rename '$base' to '$base.1' ($!)"); + } + if (-e "$base.metadata" && !rename "$base.metadata", "$base.1.metadata") { + HEXIT('ERR_INTERNAL', msg => "Coudn't rename '$base.metadata' to '$base.1.metadata' ($!)"); + } + } + if (open(my $fdout, '>', $base)) { + print $fdout "$pass\n"; + close($fdout); + if ($context eq 'account') { + if (my (undef, undef, $uid, $gid) = getpwnam($account)) { + chown $uid, $gid, $base; + } + } + chmod 0440, $base; + } + else { + HEXIT('ERR_INTERNAL', msg => "Couldn't create password file in $base ($!)"); + } + + if (open(my $fdout, '>', "$base.metadata")) { + print $fdout "CREATED_BY=$self\nBASTION_VERSION=" . $OVH::Bastion::VERSION . "\nCREATION_TIME=" . localtime() . "\nCREATION_TIMESTAMP=" . time() . "\n"; + close($fdout); + if ($context eq 'account') { + if (my (undef, undef, $uid, $gid) = getpwnam($account)) { + chown $uid, $gid, "$base.metadata"; + } + } + chmod 0440, "$base.metadata"; + } + else { + osh_warn "Couldn't create metadata file, proceeding anyway"; + } + + return R('OK', value => {context => $context, group => $shortGroup, account => $account, hashes => $hashes}); +} + +1; diff --git a/lib/perl/OVH/Bastion/Plugin/groupSetRole.pm b/lib/perl/OVH/Bastion/Plugin/groupSetRole.pm new file mode 100644 index 0000000..1f36258 --- /dev/null +++ b/lib/perl/OVH/Bastion/Plugin/groupSetRole.pm @@ -0,0 +1,314 @@ +package OVH::Bastion::Plugin::groupSetRole; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; + +sub preconditions { + my %params = @_; + my ($self, $account, $group, $action, $type, $user, $userAny, $port, $portAny, $host, $ttl, $sudo, $silentoverride) = + @params{qw{ self account group action type user userAny port portAny host ttl sudo silentoverride }}; + my $fnret; + + if (!$self || !$account || !$group || !$type || !$action) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument self[$self], account[$account], group[$group], type[$type] or action[$action]"); + } + + if (!grep { $action eq $_ } qw{ add del }) { + return R('ERR_INVALID_PARAMETER', msg => "Action should be add or del"); + } + + # a regex is overkill here but we need it for untaint + if ($type !~ /^(owner|gatekeeper|aclkeeper|member|guest)$/) { ## no critic (ProhibitFixedStringMatches) + return R('ERR_INVALID_PARAMETER', msg => "Type should be either owner, gatekeeper, aclkeeper, member or guest"); + } + + # untaint it: + $type = $1; ## no critic (ProhibitCaptureWithoutTest) + + if ($type eq 'guest' && !$sudo) { + + # guest access need (user||user-any), host and (port||port-any) + # in sudo mode, these are not used, because the helper doesn't handle the guest access add by itself, the do() func of this package does + if (!($user xor $userAny)) { + return R('ERR_MISSING_PARAMETER', msg => "Require exactly one argument of user or user-any"); + } + if (!($port xor $portAny)) { + return R('ERR_MISSING_PARAMETER', msg => "Require exactly one argument of port or port-any"); + } + if (not $host) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument host for type guest"); + } + if ($port) { + $fnret = OVH::Bastion::is_valid_port(port => $port); + $fnret or return $fnret; + } + if ($user and $user !~ /^[a-zA-Z0-9!._-]+$/) { + return R('ERR_INVALID_PARAMETER', msg => "Invalid remote user ($user) specified"); + } + + # policy check for guest accesses: if group forces ttl, the account creation must comply + $fnret = OVH::Bastion::group_config(group => $group, key => "guest_ttl_limit"); + + # if this config key is not set, no policy enforce has been requested, otherwise, check it: + if ($fnret) { + my $max = $fnret->value(); + if (!$ttl) { + return R('ERR_INVALID_PARAMETER', + msg => "This group requires guest accesses to have a TTL set, to a duration of " + . OVH::Bastion::duration2human(seconds => $max)->value->{'duration'} + . " or less"); + } + if ($ttl > $max) { + return R('ERR_INVALID_PARAMETER', + msg => "The TTL you specified is invalid, this group requires guest accesses to have a TTL of " + . OVH::Bastion::duration2human(seconds => $max)->value->{'duration'} + . " maximum"); + } + } + } + + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); + $fnret or return $fnret; + + # get returned untainted value + $group = $fnret->value->{'group'}; + my $shortGroup = $fnret->value->{'shortGroup'}; + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + # get returned untainted value + $account = $fnret->value->{'account'}; + my $realm = $fnret->value->{'realm'}; + my $remoteaccount = $fnret->value->{'remoteaccount'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + + if ($self eq 'root' && $< == 0) { + osh_debug("called by root, allowing anyway"); + } + else { + my $neededright = 'unknown'; + if (grep { $type eq $_ } qw{ owner gatekeeper aclkeeper }) { + $neededright = "owner"; + $fnret = OVH::Bastion::is_group_owner(account => $self, group => $shortGroup, superowner => 1, sudo => $sudo); + if (!$fnret) { + osh_debug("user $self not an owner of $shortGroup"); + return R('ERR_NOT_GROUP_OWNER', msg => "Sorry, you're not an owner of group $shortGroup, which is needed to change its $type list"); + } + + # if account is from a realm, he can't be owner/gk/aclk + if (defined $realm) { + return R('ERR_REALM_USER', msg => "Sorry, $account is from another realm, this account can't be $type"); + } + } + elsif (grep { $type eq $_ } qw{ member guest }) { + $neededright = "gatekeeper"; + $fnret = OVH::Bastion::is_group_gatekeeper(account => $self, group => $shortGroup, superowner => 1, sudo => $sudo); + if (!$fnret) { + osh_debug("user $self not a gk of $shortGroup"); + return R('ERR_NOT_GROUP_GATEKEEPER', msg => "Sorry, you're not a gatekeeper of group $shortGroup, which is needed to change its $type list"); + } + } + else { + return R('ERR_INTERNAL', msg => "Unknown type $type"); + } + + if ($fnret->value() and $fnret->value()->{'superowner'} and not $silentoverride) { + osh_warn "SUPER OWNER OVERRIDE: You're not a $neededright of the group $shortGroup,"; + osh_warn "but allowing because you're a superowner. This has been logged."; + + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'superowner-override'], ['account', $params{'self'}], ['plugin', $params{'scriptName'}], ['params', $params{'savedArgs'}],] + ); + } + } + + return R('OK', + value => {group => $group, shortGroup => $shortGroup, account => $account, type => $type, realm => $realm, remoteaccount => $remoteaccount, sysaccount => $sysaccount}); +} + +sub act { + my %params = @_; + my $fnret = preconditions(%params); + $fnret or return $fnret; + + # get returned untainted value + my %values = %{$fnret->value()}; + my ($group, $shortGroup, $account, $type, $realm, $remoteaccount, $sysaccount) = @values{qw{ group shortGroup account type realm remoteaccount sysaccount }}; + my ($action, $self, $user, $host, $port, $ttl) = @params{qw{ action self user host port ttl }}; + + undef $user if $params{'userAny'}; + undef $port if $params{'portAny'}; + my @command; + + osh_debug("groupSetRole::act, $action $type $group/$account ($sysaccount/$realm/$remoteaccount) $user\@$host:$port ttl=$ttl"); + + # add/del system user to system group except if we're removing a guest access (will be done after if needed) + if (!($type eq 'guest' and $action eq 'del')) { + @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupSetRole'; + push @command, '--type', $type; + push @command, '--group', $group; + push @command, '--account', $account, '--action', $action; + $fnret = OVH::Bastion::helper(cmd => \@command); + $fnret or return $fnret; + } + + if ($type eq 'member') { + + # in that case, we also need to handle the symlink + @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupAddSymlinkToAccount'; + push @command, '--group', $group; # must be first params, forced in sudoers.d + push @command, '--account', $account; + push @command, '--action', $action; + $fnret = OVH::Bastion::helper(cmd => \@command); + $fnret or return $fnret; + if ($fnret->err eq 'OK_NO_CHANGE') { + + # make the error msg user friendly + $fnret->{'msg'} = "Account $account was already " . ($action eq 'del' ? 'not ' : '') . "a member of $shortGroup, nothing to do"; + } + } + elsif ($type eq 'guest') { + + # in that case, we need to handle the add/del of the guest access to $user@$host:$port + # check if group has access to $user@$ip:$port + my $machine = $host; + $port and $machine .= ":$port"; + $user and $machine = $user . '@' . $machine; + osh_debug("groupSetRole::act, checking if group $group has access to $machine to $action $type access to $account"); + + if ($action eq 'add') { + + $fnret = OVH::Bastion::is_access_way_granted( + way => 'group', + group => $shortGroup, + user => $user, + port => $port, + ip => $host, + ); + if (not $fnret) { + osh_debug("groupSetRole::act, it doesn't! $fnret"); + return R('ERR_GROUP_HAS_NO_ACCESS', + msg => "The group $shortGroup doesn't have access to $machine, so you can't add a guest group access " + . "to it (first add it to the group if applicable, with groupAddServer)"); + } + } + + # Add/Del user access to user@host:port with group key + @command = qw{ sudo -n -u allowkeeper -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountAddGroupServer'; + push @command, '--group', $group; # must be first params, forced in sudoers.d + push @command, '--account', $account; + push @command, '--action', $action; + push @command, '--ip', $host; + push @command, '--user', $user if $user; + push @command, '--port', $port if $port; + push @command, '--ttl', $ttl if $ttl; + + $fnret = OVH::Bastion::helper(cmd => \@command); + $fnret or return $fnret; + + if ($fnret->err eq 'OK_NO_CHANGE') { + if ($action eq 'add') { + osh_info "Account $account already had access to $machine through $shortGroup"; + } + else { + osh_info "Account $account didn't have access to $machine through $shortGroup"; + } + } + else { + if ($action eq 'add') { + osh_info "Account $account has now access to the group key of $shortGroup, but does NOT"; + osh_info "automatically inherits access to any of the group's servers, only to $machine,"; + osh_info "and any other(s) $shortGroup group server(s) previously granted to $account."; + osh_info "This access will expire in " . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'} if $ttl; + } + else { + osh_info "Access to $machine through group $shortGroup was removed from account $account"; + } + } + + if ($action eq 'del') { + + # if the guest group access file of this account is now empty, we should remove the account from the group + # but ONLY if the account doesn't have regular member access to the group too. + my $accessesFound = 0; + if (!$realm) { + + # in non-realm mode, just check the account itself + $fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => $account); + $fnret or return $fnret; + $accessesFound += @{$fnret->value}; + } + else { + # in realm-mode, we need to check that all the other remote accounts no longer have access either, before removing the key + $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $realm); + $fnret or return $fnret; + foreach my $pRemoteaccount (@{$fnret->value}) { + $fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => "$realm/$pRemoteaccount"); + $accessesFound += @{$fnret->value}; + last if $accessesFound > 0; + } + } + + if ($accessesFound == 0 && !OVH::Bastion::is_group_member(group => $shortGroup, account => $account)) { + osh_debug "No guest access remains to group $shortGroup for account $account, removing group key access"; + # + # remove account from group + # + @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupSetRole'; + push @command, '--type', 'guest'; + push @command, '--group', $group; + push @command, '--account', $account; + push @command, '--action', 'del'; + + $fnret = OVH::Bastion::helper(cmd => \@command); + $fnret or return $fnret; + + if (!$realm) { + osh_info "No guest access to servers of group $shortGroup remained for account $account, removed group key access"; + } + else { + osh_info "No guest access to servers of group $shortGroup remained for realm $realm, removed group key access"; + } + } + } + else { + osh_info "\nYou can view ${account}'s guest accesses to $shortGroup with the following command:"; + my $bastionName = OVH::Bastion::config('bastionName')->value(); + osh_info "$bastionName --osh groupListGuestAccesses --account $account --group $shortGroup"; + } + } + + if ($fnret) { + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'membership', + fields => [ + ['action', $action], + ['type', $type], + ['group', $shortGroup], + ['account', $account], + ['self', $self], + ['user', $user], + ['host', $host], + ['port', $port], + ['ttl', $ttl], + ] + ); + } + + return $fnret; +} + +1; diff --git a/lib/perl/OVH/Bastion/ProxyHTTP.pm b/lib/perl/OVH/Bastion/ProxyHTTP.pm new file mode 100644 index 0000000..e3cd85c --- /dev/null +++ b/lib/perl/OVH/Bastion/ProxyHTTP.pm @@ -0,0 +1,500 @@ +package OVH::Bastion::ProxyHTTP; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; + +use CGI; +use Fcntl qw(:flock); +use Time::HiRes (); +use MIME::Base64; +use Net::Server::PreForkSimple; +use Net::Server::PreFork; +use Sys::Hostname; +use base qw{Net::Server::HTTP}; + +########################### +# BE CAREFUL IN THIS CLASS: STDIN && STDOUT are bound to the server->client socket +# of the current request, in other words, everything that is printed to stdout goes +# to the network, this is NOT the case with stderr + +# override Net::Server::HTTP::send_status, because it hardcodes content-type: text/html, +# and there's no way for the caller to prevent that :( +sub send_status { + my ($self, $status, $msg, $body) = @_; + $msg ||= ($status == 200) ? 'OK' : '-'; + my $request_info = $self->{'request_info'}; + + my $content_type_already_sent = 0; + my $want_gzip = 0; + my $out = "HTTP/1.0 $status $msg\r\n"; + foreach my $row (@{$self->http_base_headers}) { + $out .= "$row->[0]: $row->[1]\r\n"; + push @{$request_info->{'response_headers'}}, $row; + $content_type_already_sent++ if (lc($row->[0]) eq 'content-type'); + $want_gzip++ if (lc($row->[0]) eq 'content-encoding' && $row->[1] =~ /gzip/); + } + $self->{'server'}->{'client'}->print($out); + $request_info->{'http_version'} = '1.0'; + $request_info->{'response_status'} = $status; + $request_info->{'response_header_size'} += length $out; + + if ($body) { + + # add Content-Type only if not already defined + if (not $content_type_already_sent) { + push @{$request_info->{'response_headers'}}, ['Content-type', 'text/plain']; + $out = "Content-Type: text/plain\r\n\r\n"; + } + else { + $out = "\r\n"; + } + $request_info->{'response_header_size'} += length $out; + $self->{'server'}->{'client'}->print($out); + $request_info->{'headers_sent'} = 1; + my $encoded_body = $body; + if ($want_gzip) { + require IO::Compress::Gzip; + IO::Compress::Gzip::gzip(\$body => \$encoded_body); + } + $self->{'server'}->{'client'}->print($encoded_body); + $request_info->{'response_size'} += length $encoded_body; + } + + return 1; +} + +sub log_and_exit { + my ($self, $code, $msg, $body, $params) = @_; + $params->{'returnvalue'} ||= "$code $msg"; + + my $account = delete $self->{'_log'}{'account'}; + my $user = delete $self->{'_log'}{'user'}; + my $hostto = delete $self->{'_log'}{'hostto'}; + my $portto = delete $self->{'_log'}{'portto'}; + my $starttime = delete $self->{'_log'}{'start_time'}; + my $allowed = delete $self->{'_log'}{'allowed'} || 0; + my $bastion2device_delay = delete $self->{'_log'}{'bastion2device_delay'}; + my $request_body_length = delete $self->{'_log'}{'request_body_length'}; + + # log in sql and/or logfile and/or syslog + my $processing_delay = ($starttime ? int(Time::HiRes::tv_interval($starttime) * 1_000_000) : undef); + $params->{'account'} = $account; # might be undef if we're called before the account is extracted from the payload + $params->{'user'} = $user; # ditto + $params->{'hostto'} = $hostto; # ditto + $params->{'portto'} = $portto; # ditto + $params->{'loghome'} = 'proxyhttp'; + $params->{'cmdtype'} = 'proxyhttp_daemon'; + $params->{'ipfrom'} = $self->{'request_info'}{'peeraddr'}; + $params->{'portfrom'} = $self->{'request_info'}{'peerport'}; + $params->{'bastionip'} = $self->{'request_info'}{'sockaddr'}; + $params->{'bastionport'} = $self->{'request_info'}{'sockport'}; + $params->{'params'} = $self->{'request_info'}{'request_path'}; + $params->{'plugin'} = uc($self->{'request_info'}{'request_method'}); + $params->{'allowed'} = $allowed; + + # custom data will only be logged to logfile and syslog, not sql (it's not in the generic schema) + $params->{'custom'} = [ + ['user_agent' => $ENV{'HTTP_USER_AGENT'}], + ['request_headers_length' => $self->{'request_info'}{'request_header_size'}], + ['request_body_length' => $request_body_length + 0], + ['response_body_length' => length($body)], + ['timing_bastion2device_usec' => $bastion2device_delay], + ['timing_global_usec' => $processing_delay], + ['code' => $code], + ['msg' => $msg], + ]; + if ($processing_delay) { + push @{$params->{'custom'}}, ['timing_overhead_usec' => ($processing_delay - $bastion2device_delay) + 0]; + } + $self->{'_log'}{'logret'} = OVH::Bastion::log_access_insert(%$params); + + # log in "ttyrec" + my $basedir = "/home/proxyhttp/ttyrec"; + -d $basedir || mkdir $basedir; + + my $srcip = 'src_' . ($ENV{'REMOTE_ADDR'} || '0.0.0.0'); + my $finaldir = "$basedir/$srcip"; + -d $finaldir || mkdir $finaldir; + + my @now = Time::HiRes::gettimeofday(); + my @t = localtime($now[0]); + + my @request_lines = ($self->{'request_info'}{'request'}); + foreach my $array (@{$self->{'request_info'}{'request_headers'} || []}) { + push @request_lines, sprintf("%s: %s", @$array); + } + if (exists $self->{'_log'}{'post_content'}) { + push @request_lines, ''; + push @request_lines, delete $self->{'_log'}{'post_content'}; + } + + my $logfile = sprintf("%s/%s.txt", $finaldir, POSIX::strftime("%F", @t)); + my $logline = sprintf( + "--- CLIENT_REQUEST UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n" + . "--- BASTION_ANSWER UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n" + . "--- END UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n\n", + $ENV{'UNIQID'}, $now[0], $now[1], + POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), + join("\n", @request_lines), + $ENV{'UNIQID'}, $now[0], $now[1], + POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), + "HTTP/1.0 $code $msg\n\n$body", + $ENV{'UNIQID'}, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), + ); + $logline =~ s/^(Authorization:).+/$1 (removed)/mgi; + + if (open(my $log, '>>', $logfile)) { + flock($log, LOCK_EX); + print $log $logline; + flock($log, LOCK_UN); + close($log); + } + else { + warn("Couldn't open $logfile for log write"); + } + + # if status is 401, tell client what scheme we expect for the auth + if ($code == 401) { + push @{$self->{'_supplementary_headers'}}, ['WWW-Authenticate', 'Basic realm="bastion"']; + } + + # and send status (will also fills access_log) + return $self->send_status($code, $msg, $body . "\n"); +} + +# called by Net::Server when initializing, we set its log_function here to handle error logs, such as timeouts. +# if func is undefined when Net::Server needs it, it'll log to STDERR, and we don't want that +sub configure_hook { ## no critic (RequireFinalReturn) + my $self = shift; + $self->{'server'}{'log_function'} = sub { + my ($level, $msg) = @_; + warn_syslog("osh-http-proxy-daemon: level $level: $msg"); + } +} + +# overrides parent func +sub run { + my ($self, %params) = @_; + $self->{'proxy_config'} = (delete $params{'proxy_config'}) || {}; + return $self->SUPER::run($self, %params); +} + +# overrides parent func +sub process_http_request { + my ($self, $client) = @_; + my $fnret; + + $ENV{'FORCE_STDERR'} = 1; + $self->{'_log'}{'start_time'} = [Time::HiRes::gettimeofday()]; + + $ENV{'UNIQID'} = OVH::Bastion::generate_uniq_id()->value; + + $self->{'server'}{'access_log_format'} = qq#%h - - %t "%r" %>s %b "-" "$ENV{'UNIQID'}" "%{User-Agent}i" %D -#; + + # consistency check + if ($self->{'request_info'}{'peerport'} ne $ENV{'REMOTE_PORT'}) { + return $self->log_and_exit( + 500, + "Internal Server Error (consistency)", + "Internal consistency error: remote_port doesn't match peerport, this shouldn't happen", + {comment => 'consistency_error'} + ); + } + + # only GET or POST are allowed + if (not grep { uc($self->{'request_info'}{'request_method'}) eq $_ } qw{ GET POST }) { + return $self->log_and_exit(400, "Bad Request (method forbidden)", "Only GET and POST methods are allowed", {comment => 'method_forbidden'}); + } + + # if we don't have the request_headers, we really have a big problem + if (ref $self->{'request_info'} ne 'HASH' or ref $self->{'request_info'}{'request_headers'} ne 'ARRAY') { + return $self->log_and_exit(500, "Internal Server Error (headers not found)", "The headers of the request can't be found", {comment => "headers_not_found"}); + } + + # convert headers into a hash + my $req_headers = _flatten_headers($self->{'request_info'}{'request_headers'}); + if (ref $req_headers ne 'HASH') { + return $self->log_and_exit(500, "Internal Server Error (headers are not a hash)", "Request headers couldn't be parsed properly", {comment => "headers_cannot_be_parsed"}); + } + + # check if it's not just a self-health test + if ($ENV{'REQUEST_URI'} eq '/bastion-health-check') { + + # launch the worker in monitoring mode, to be sure we test all the sudo part + my @cmd = ("sudo", "-n", "-u", "proxyhttp", "--", "/usr/bin/env", "perl", "-T", "/opt/bastion/bin/proxy/osh-http-proxy-worker"); + push @cmd, "--monitoring", "--uniqid", $ENV{'UNIQID'}; + + $fnret = OVH::Bastion::execute(cmd => \@cmd, noisy_stdout => 0, noisy_stderr => 1, is_helper => 1); + $fnret + or return $self->log_and_exit(500, "Internal Error (couldn't call helper)", "Couldn't call helper (" . $fnret->msg . ")", {comment => "exec1_failed"}); + + $fnret = OVH::Bastion::result_from_helper($fnret->value->{'stdout'}); + $fnret + or return $self->log_and_exit(500, "Internal Error (helper execution failed)", "Helper execution failed (" . $fnret->msg . ")", {comment => "exec2_failed"}); + + $fnret = OVH::Bastion::helper_decapsulate($fnret->value); + $fnret + or return $self->log_and_exit(500, "Internal Error (helper returned an error)", "Helper returned an error (" . $fnret->msg . ")", {comment => "exec3_failed"}); + + my $workerversion = $fnret->value->{'body'}; + + if ($workerversion eq $OVH::Bastion::VERSION) { + return $self->log_and_exit(200, "OK", "Bastion HTTPS proxy version " . $OVH::Bastion::VERSION . " is running nominally.", {comment => "monitoring"}); + } + else { + return $self->log_and_exit( + 202, + "Semi-OK", + "A discrepancy was found between the Bastion HTTPS proxy daemon version (" + . $OVH::Bastion::VERSION + . ") and worker version ($workerversion), please reload the daemon to avoid problems.", + {comment => "monitoring"} + ); + } + } + + # this header is mandatory, and must be a Basic scheme auth + if (not $req_headers->{'authorization'}) { + return $self->log_and_exit(401, "Authorization required (no auth provided)", "No authentication provided, and authentication is mandatory", + {comment => "no_auth_provided"}); + } + my $basic_auth_header_value; + if (not $req_headers->{'authorization'} =~ m{^Basic (\S+)$}i) { + return $self->log_and_exit(401, "Authorization required (basic auth scheme needed)", "Basic authorization scheme required", {comment => "bad_auth_scheme"}); + } + else { + $basic_auth_header_value = $1; + } + if ($req_headers->{'authorization'} ne $ENV{'HTTP_AUTHORIZATION'}) { + return $self->log_and_exit(500, "Internal Server Error (consistency)", "Internal consistency error: authorization header doesn't match envvar", + {comment => 'consistency_error'}); + } + delete $ENV{'HTTP_AUTHORIZATION'}; + + # decode the auth header + my $decoded = decode_base64($basic_auth_header_value); + undef $basic_auth_header_value; + + #print STDERR "I decoded $decoded\n"; + + # the decoded header should be of the form LOGIN:PASSWORD + if (not $decoded or $decoded !~ /^(.+):([^:]+)$/) { + return $self->log_and_exit(401, "Authorization required (malformed basic auth)", "Malformed Basic authorization '$decoded'", {comment => "malformed_basic_auth"}); + } + + # in our case, the LOGIN should in fact be of the form bastion_account@remote_login_expression@remote_host_to_connect_to, + # where remote_login_expression can be one of "$user", "group=$shortGroup,user=$user" or "user=$user" + my ($loginpart, $pass) = ($1, $2); ## no critic (ProhibitCaptureWithoutTest) + if ($loginpart !~ m{^([^@]+)@([^@]+)@([0-9a-zA-Z._-]+)(%(\d+))?$}) { ## no critic (ProhibitUnusedCapture) + return $self->log_and_exit( + 400, + "Bad Request (bad login format)", + "Expected an Authorization line with credentials of the form 'BASTIONACCOUNT\@DEVICEUSER\@HOST' or " + . "'BASTIONACCOUNT\@group=BASTIONGROUP,user=DEVICEUSER\@HOST' or 'BASTIONACCOUNT\@user=DEVICEUSER\@HOST'", + {comment => "bad_login_format"} + ); + } + my ($account, $user_expression, $remotemachine, $remoteport) = ($1, $2, $3, $5); ## no critic (ProhibitCaptureWithoutTest) + undef $loginpart; # no longer needed + $remoteport = 443 if not defined $remoteport; + $self->{'_log'}{'hostto'} = $remotemachine; + $self->{'_log'}{'portto'} = $remoteport; + + my $context; + my $group; + my $user; + if ($user_expression =~ m{^group=(\S+),user=(\S+)$}) { + $context = 'group'; + $group = $1; + $user = $2; + } + elsif ($user_expression =~ m{^user=(\S+),group=(\S+)$}) { + $context = 'group'; + $group = $2; + $user = $1; + } + elsif ($user_expression =~ m{^user=(\S+)$}) { + $context = 'self'; + $user = $1; + } + else { + $context = 'autodetect'; + $user = $user_expression; + } + undef $user_expression; # no longer needed + + if (not OVH::Bastion::is_account_valid(account => $account)) { + return $self->log_and_exit(400, "Bad Request (bad account)", "Account name is invalid", {comment => "invalid_account"}); + } + my $escaped_account = $account; + $escaped_account =~ s/%/%%/g; + $self->{'server'}{'access_log_format'} = qq#%h $escaped_account %u %t "%r" %>s %b "$remotemachine" "$ENV{'UNIQID'}" "%{User-Agent}i" %D -#; + if (not OVH::Bastion::is_valid_port(port => $remoteport)) { + return $self->log_and_exit(400, "Bad Request (bad port number)", "Port number is out of range", {comment => "invalid_port_number"}); + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + if (not $fnret) { + + # don't be too specific on the error message to avoid account name guessing + return $self->log_and_exit(403, "Access Denied", "Incorrect username ($account) or password (#REDACTED#, length=" . length($pass) . ")", + {comment => "invalid_credentials"}); + } + $account = $fnret->value->{'account'}; # untaint + $self->{'_log'}{'account'} = $account; + + if ($user !~ /^[a-zA-Z0-9._-]+/) { + return $self->log_and_exit(400, "Bad Request (bad user name)", "User name '$user' has forbidden characters", {comment => "bad_user_name"}); + } + $self->{'_log'}{'user'} = $user; + my $escaped_user = $user; + $escaped_user =~ s/%/%%/g; + $self->{'server'}{'access_log_format'} = qq#%h $escaped_account $escaped_user %t "%r" %>s %b "$remotemachine" "$ENV{'UNIQID'}" "%{User-Agent}i" %D -#; + + # config value by default + my $timeout = $self->{'proxy_config'}{'timeout'}; + + # if there's a timeout header, get it + if ($req_headers->{'x-bastion-timeout'}) { + if ($req_headers->{'x-bastion-timeout'} =~ /^\d+$/) { + $timeout = $req_headers->{'x-bastion-timeout'}; + } + else { + return $self->log_and_exit(400, "Bad Request (invalid timeout value)", "Expected an integer timeout value expressed in seconds", {comment => "bad_timeout_value"}); + } + } + + # if there's an allow-downgrade header, get it + my $allow_downgrade = 0; + if ($req_headers->{'x-bastion-allow-downgrade'}) { + if ($req_headers->{'x-bastion-allow-downgrade'} eq "1" || $req_headers->{'x-bastion-allow-downgrade'} eq "0") { + $allow_downgrade = $req_headers->{'x-bastion-allow-downgrade'} + 0; + } + else { + return $self->log_and_exit(400, "Bad Request (invalid allow-downgrade value)", "Expected value '0', '1' or no header", {comment => "bad_allow_downgrade_value"}); + } + } + + # if there's an enforce-secure header, get it + my $enforce_secure = 0; + if ($req_headers->{'x-bastion-enforce-secure'}) { + if ($req_headers->{'x-bastion-enforce-secure'} eq "1" || $req_headers->{'x-bastion-enforce-secure'} eq "0") { + $enforce_secure = $req_headers->{'x-bastion-enforce-secure'} + 0; + } + else { + return $self->log_and_exit(400, "Bad Request (invalid enforce-secure value)", "Expected value '0', '1' or no header", {comment => "bad_enforce_secure_value"}); + } + } + + # here, we know the account is right, so we sudo to this account to proceed + my @cmd = ("sudo", "-n", "-u", $account, "--", "/usr/bin/env", "perl", "-T", "/opt/bastion/bin/proxy/osh-http-proxy-worker"); + push @cmd, "--account", $account, "--context", $context, "--user", $user, "--host", $remotemachine, "--uniqid", $ENV{'UNIQID'}; + push @cmd, "--method", $self->{'request_info'}{'request_method'}, "--path", $self->{'request_info'}{'request_path'}; + push @cmd, "--port", $remoteport; + push @cmd, "--group", $group if $group; + push @cmd, "--timeout", $timeout if $timeout; + push @cmd, "--allow-downgrade" if $allow_downgrade; + push @cmd, "--insecure" if ($self->{'proxy_config'}{'insecure'} && !$enforce_secure); + foreach my $key (qw{ accept content-type connection }) { + push @cmd, "--header", $key . ':' . $req_headers->{$key} if (defined $req_headers->{$key}); + } + + # we don't want the CGI module to parse/modify/interpret the content, so we + # fake an application/xml content, this has the effect in the CGI module code + # to not mess at all with the data, which is what we want. This way we can get the + # raw unparsed/unmodified data through the special 'XForms:Model' param. Once done, + # we simply restore the real content-type + my $real_content_type = $ENV{'CONTENT_TYPE'}; + $ENV{'CONTENT_TYPE'} = 'application/xml'; + my $content = CGI->new->param('XForms:Model'); + $ENV{'CONTENT_TYPE'} = $real_content_type; + $ENV{'PROXY_POST_DATA'} = encode_base64($content); + + $ENV{'PROXY_ACCOUNT_PASSWORD'} = $pass; + undef $pass; + $self->{'_log'}{'request_body_length'} = length($content); + $fnret = OVH::Bastion::execute(cmd => \@cmd, noisy_stdout => 0, noisy_stderr => 1, is_helper => 1); + $fnret + or return $self->log_and_exit(500, "Internal Error (couldn't call helper)", "Couldn't call helper (" . $fnret->msg . ")", {comment => "exec1_failed"}); + delete $ENV{'PROXY_ACCOUNT_PASSWORD'}; + delete $ENV{'PROXY_POST_DATA'}; + + $fnret = OVH::Bastion::result_from_helper($fnret->value->{'stdout'}); + $fnret + or return $self->log_and_exit(500, "Internal Error (helper execution failed)", "Helper execution failed (" . $fnret->msg . ")", {comment => "exec2_failed"}); + + $fnret = OVH::Bastion::helper_decapsulate($fnret->value); + $fnret + or return $self->log_and_exit(500, "Internal Error (helper returned an error)", "Helper returned an error (" . $fnret->msg . ")", {comment => "exec3_failed"}); + + if (ref $fnret->value->{'headers'} eq 'ARRAY') { + push @{$self->{'_supplementary_headers'}}, @{$fnret->value->{'headers'}}; + } + if ($req_headers->{'accept-encoding'} =~ /gzip/) { + push @{$self->{'_supplementary_headers'}}, ['Content-Encoding', 'gzip']; + } + $self->{'request_info'}{'headers_sent'} = 1; # needed to avoid duplicate headers by our parent package + + my $flattened_bastion2client_headers = _flatten_headers($self->{'_supplementary_headers'}); + my $bastion2devicedelay = $flattened_bastion2client_headers->{'x-bastion-egress-timing'}; + $self->{'_log'}{'post_content'} = $content; + $self->{'server'}{'access_log_format'} = + qq#%h $escaped_account $escaped_user %t "%r" %>s %b "$remotemachine" "$ENV{'UNIQID'}" "%{User-Agent}i" %D # . ($bastion2devicedelay || '-'); + + $self->{'_log'}{'bastion2device_delay'} = $bastion2devicedelay; + $self->{'_log'}{'allowed'} = $fnret->value->{'allowed'}; + $self->log_and_exit($fnret->value->{'code'}, $fnret->value->{'msg'}, $fnret->value->{'body'}, {comment => "worker_returned"}); + + return 1; +} + +# overrides parent func +sub http_base_headers { + my $self = shift; + my @headers = ( + [Date => gmtime() . " GMT"], + [Connection => 'close'], + [Server => "The Bastion " . $OVH::Bastion::VERSION], + ['X-Bastion-Instance' => hostname()], + ['X-Bastion-ReqID' => $ENV{'UNIQID'}], + ); + foreach my $keyval (@{$self->{'_supplementary_headers'}}) { + my $keyname = $keyval->[0]; + $keyname = 'X-Bastion-Remote-' . $keyname if ($keyname =~ /^(client-ssl-)/i); + push @headers, [$keyname, $keyval->[1]]; + } + delete $self->{'_supplementary_headers'}; + return \@headers; +} + +# this sub turns [ [ HeaDerA, ValueA ], [ HEAderB, ValueB ] ] +# into { headera => ValueA, headerb => ValueB } +# and errors if there is a duplicate header somewhere +sub _flatten_headers { + my $arrayref = shift; + my %headers; + + if (ref $arrayref ne 'ARRAY') { + return "Bad call"; + } + + foreach my $keyval (@$arrayref) { + if (ref $keyval ne 'ARRAY' or @$keyval != 2) { + return "Malformed headers"; + } + my $key = lc($keyval->[0]); + my $val = $keyval->[1]; + if (exists($headers{$key})) { + return "Duplicate header $key"; + } + $headers{$key} = $val; + } + return \%headers; +} + +1; diff --git a/lib/perl/OVH/Bastion/allowdeny.inc b/lib/perl/OVH/Bastion/allowdeny.inc new file mode 100644 index 0000000..00faf23 --- /dev/null +++ b/lib/perl/OVH/Bastion/allowdeny.inc @@ -0,0 +1,1181 @@ +package OVH::Bastion; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: + +use common::sense; + +use Socket qw{ :all }; + +sub get_personal_account_keys { + my %params = @_; + my $account = $params{'account'}; + my $listOnly = $params{'listOnly'} ? 1 : 0; + my $forceKey = $params{'forceKey'}; + my $fnret; + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => ($account =~ /^realm_/ ? "realm" : "normal")); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; # untainted version + + return _get_pub_keys_from_directory( + dir => "/home/$account/.ssh", + pattern => qr/^private\.pub$|^id_[a-z0-9]+[_.]private\.\d+\.pub$/, + listOnly => $listOnly, # don't be slow and don't parse the keys (by calling ssh-keygen -lf) + forceKey => $forceKey, + wantPrivate => 1, + ); +} + +my %_cache_get_group_keys; + +sub get_group_keys { + my %params = @_; + my $group = $params{'group'}; + my $cache = $params{'cache'}; # allow cache use (useful for multicall) + my $listOnly = $params{'listOnly'} ? 1 : 0; + my $forceKey = $params{'forceKey'}; + my $fnret; + + my $cacheKey = "$group:$listOnly"; + + if ($cache and exists $_cache_get_group_keys{$cacheKey}) { + return $_cache_get_group_keys{$cacheKey}; + } + + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or return $fnret; + $group = $fnret->value->{'group'}; # untainted version + my $shortGroup = $fnret->value->{'shortGroup'}; + + $fnret = _get_pub_keys_from_directory( + dir => '/home/keykeeper/' . $group, + pattern => qr/^id_([a-z0-9]+)_\Q$shortGroup\E/, + listOnly => $listOnly, + forceKey => $forceKey, + wantPrivate => 1, + ); + $_cache_get_group_keys{$cacheKey} = $fnret; + return $fnret; +} + +# this function simply checks if the user@ip:port is allowed in the way given, +# i.e. personal access, group access, groupguest access, or legacy access. +# it calls is_access_granted_in_file with the proper file location depending +# on the access way that is tested. note that for e.g. group accesses, we don't +# check if a given account has access to the group or not, we just check if the +# group itself has access. this check must be done by our caller. +# returns: { match, size, forceKey } for best match, if any +sub is_access_way_granted { + my %params = @_; + + my $exactIpMatch = $params{'exactIpMatch'}; # $ip must be explicitely allowed (not given through a wider slash or a 0.0.0.0/0 in grantfile) + my $exactPortMatch = $params{'exactPortMatch'}; # $port must be explicitely allowed (port wildcards in grantfile will be ignored) + my $exactUserMatch = $params{'exactUserMatch'}; # $user must be explicitely allowed (user wildcards in grantfile will be ignored) + my $exactMatch = $params{'exactMatch'}; # sets exactIpMatch exactPortMatch and exactUserMatch + + my $ignoreUser = $params{'ignoreUser'}; # ignore remote user COMPLETELY (plop@, or root@, or @ will all match) + my $ignorePort = $params{'ignorePort'}; # ignore port COMPLETELY (port 22, 2345, or port-wildcard will all match) + + my $wantedUser = $params{'user'}; # if undef, means we look for a user wildcard allow + my $wantedIp = $params{'ip'}; # can be a single IP or a prefix + my $wantedPort = $params{'port'}; # if undef, means we look for a port wildcard allow + + my $way = $params{'way'}; # personal|group|groupguest|legacy + my $group = $params{'group'}; # only meaningful and needed if type=group or type=groupguest + my $account = $params{'account'}; # only meaningful and needed if type=personal or type=groupguest + + my $fnret; + + $exactIpMatch = $exactPortMatch = $exactUserMatch = 1 if $exactMatch; + + # 'group', 'account', and 'way' parameters are only useful to, and checked by, get_acl_way() + $fnret = OVH::Bastion::get_acl_way(way => $way, account => $account, group => $group); + $fnret or return $fnret; + my @acl = @{$fnret->value || []}; + + osh_debug( +"checking way $way/$account/$group with ignorePort=$ignorePort ignoreUser=$ignoreUser exactIpMatch=$exactIpMatch exactPortMatch=$exactPortMatch exactUserMatch=$exactUserMatch" + ); + + my ($bestMatch, $bestMatchSize, $forceKey); + foreach my $entry (@acl) { + my $allowedIp = $entry->{'ip'}; # can be a prefix + my $allowedUser = $entry->{'user'}; # can be undef (if any-user) + my $allowedPort = $entry->{'port'}; # can be undef (if any-port) + my $localForceKey = $entry->{'forceKey'}; + + osh_debug("checking wanted " + . (defined $wantedUser ? $wantedUser : '') . '@' + . (defined $wantedIp ? $wantedIp : '') . ':' + . (defined $wantedPort ? $wantedPort : '') + . ' against ' + . (defined $allowedUser ? $allowedUser : '') . '@' + . (defined $allowedIp ? $allowedIp : '') . ':' + . (defined $allowedPort ? $allowedPort : '')); + + $allowedIp or next; # can't be empty + + # first, check port stuff + # if we get ignorePort, we skip the checks entirely + if (not $ignorePort) { + if ($exactPortMatch) { + + # we want an exact match + if (not defined $allowedPort) { + if (not defined $wantedPort) { + ; # both undefined ? ok + } + else { + next; # if only one of two is undef, it's not an exact match + } + } + else { + if (not defined $wantedPort) { + next; # if only one of two is undef, it's not an exact match + } + else { + next if ($wantedPort ne $allowedPort); # both defined but unequal, not a match + } + } + } + else { + # we don't want an exact match (aka wildcards allowed) + if (not defined $allowedPort) { + ; # it's a wildcard, will always match + } + else { + if (not defined $wantedPort) { + next; # we want a wildcard, but we don't have it + } + else { + next if ($wantedPort ne $allowedPort); # both defined but unequal, not a match + } + } + } + } + + # second, check user stuff + # if we get ignoreUser, we skip the checks entirely + if (not $ignoreUser) { + if ($exactUserMatch) { + + # we want an exact match + if (not defined $allowedUser) { + if (not defined $wantedUser) { + ; # both undefined ? ok + } + else { + next; # if only one of two is undef, it's not an exact match + } + } + else { + if (not defined $wantedUser) { + next; # if only one of two is undef, it's not an exact match + } + else { + next if ($wantedUser ne $allowedUser); # both defined but unequal, not a match + } + } + } + else { + # we don't want an exact match (aka wildcards allowed) + if (not defined $allowedUser) { + ; # it's a wildcard, will always match + } + else { + if (not defined $wantedUser) { + next; # we want a wildcard, but we don't have it + } + else { + next if ($wantedUser ne $allowedUser); # both defined but unequal, not a match + } + } + } + } + + # then, check IP + # if we want an exact match, it's a stupid strcmp() + if ($exactIpMatch) { + next if ($allowedIp ne $wantedIp); + + # here, we got a perfect match + $forceKey = $localForceKey; + $bestMatch = $allowedIp; + $bestMatchSize = undef; # not needed + last; # perfect match, don't search further + } + + # check IP in not-exactIpMatch case. if it contains / then it's a prefix + if ($allowedIp =~ m{/}) { + + # build slash and test + require Net::Netmask; + my $ipCheck = Net::Netmask->new2($allowedIp); + if ($ipCheck && $ipCheck->match($wantedIp)) { + osh_debug("... we got a slash match !"); + if (not defined $bestMatchSize or $ipCheck->size() < $bestMatchSize) { + $forceKey = $localForceKey; + $bestMatch = $allowedIp; + $bestMatchSize = $ipCheck->size(); + $bestMatchSize == 1 and last; # we won't get better than this + } + } + } + else { + # it's a single ip, so a stupid strcmp() does the trick + if ($allowedIp eq $wantedIp) { + osh_debug("... we got a singleip match !"); + $forceKey = $localForceKey; + $bestMatch = $allowedIp; + $bestMatchSize = 1; + last; + } + } + } + + if (defined $bestMatch) { + return R('OK', value => {match => $bestMatch, size => $bestMatchSize, forceKey => $forceKey}); + } + return R('KO_ACCESS_DENIED'); +} + +# from a given hostname, check if we have an ip or a range of ip or try to resolve +sub get_ip { + my %params = @_; + my $host = $params{'host'}; + my $v4 = $params{'v4'}; # allow ipv4 ? + my $v6 = $params{'v6'}; # allow ipv6 ? + + if (!$host) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'host'"); + } + + # by default, only v4 unless specified otherwise + $v4 = 1 if not defined $v4; + $v6 = 0 if not defined $v6; + + # try to see if it's already an IP + osh_debug("checking if '$host' is already an IP"); + my $fnret = OVH::Bastion::is_valid_ip(ip => $host, allowPrefixes => 0); + if ($fnret) { + osh_debug("Host $host is already an IP"); + if ( ($fnret->value->{'version'} == 4 && $v4) + || ($fnret->value->{'version'} == 6 && $v6)) + { + return R('OK', value => {ip => $fnret->value->{'ip'}, iplist => [$fnret->value->{'ip'}]}); + } + return R('ERR_INVALID_IP', msg => "IP $host version is not allowed"); + } + + osh_debug("Trying to resolve '$host' because is_valid_ip() says it's not an IP"); + my ($err, @res); + eval { + # dns resolving, v4/v6 compatible + # can croak + ($err, @res) = getaddrinfo($host, undef, {socktype => SOCK_STREAM}); + }; + return R('ERR_HOST_NOT_FOUND', msg => $@) if $@; + return R('ERR_HOST_NOT_FOUND', msg => $err) if $err; + + my %iplist; + my $lastip; + foreach my $item (@res) { + if ($item->{'family'} == AF_INET) { + next if not $v4; + } + elsif ($item->{'family'} == AF_INET6) { + next if not $v6; + } + else { + # unknown weird family ? + next; + } + my $as_text; + undef $err; + eval { + ($err, $as_text) = getnameinfo($item->{'addr'}, NI_NUMERICHOST); # NI flag: don't use dns, just unpack the binary 'addr' + }; + if (not $@ and not $err) { + $iplist{$as_text} = 1; + $lastip = $as_text; + } + } + + if (%iplist) { + return R('OK', value => {ip => $lastip, iplist => [keys %iplist]}); + } + + # %iplist empty, not resolved (?) + return R('ERR_HOST_NOT_FOUND', msg => "Unable to resolve '$host'"); +} + +# reverse-dns of an IPv4 or IPv6 +sub ip2host { + my $ip = shift; + my ($err, @sockaddr, $host); + + eval { + # ip => packedip. AI_PASSIVE: don't use dns, just build sockaddr + # can croak + ($err, @sockaddr) = getaddrinfo($ip, 0, {flags => AI_PASSIVE, socktype => SOCK_STREAM}); + }; + return R('ERR_INVALID_IP', msg => $@) if $@; + return R('ERR_INVALID_IP', msg => $err) if $err; + + eval { + # can croak + ($err, $host, undef) = getnameinfo($sockaddr[0]->{'addr'}, NI_NAMEREQD); + }; + return R('ERR_HOST_NOT_FOUND', msg => $@) if $@; + return R('ERR_HOST_NOT_FOUND', msg => $err) if $err; + + return R('OK', value => $host); +} + +# Return an array containings the groups for which user is a member of +my %_cache_get_user_groups; + +sub get_user_groups { + my %params = @_; + my $user = $params{'user'} || $params{'account'}; + my $extra = $params{'extra'}; # Do we want to include gatekeeper/aclkeeper/owner groups ? + my $cache = $params{'cache'}; # allow cache use (multicall) + + if (not $user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); + } + + if (not %_cache_get_user_groups) { + + # build cache, it'll be faster than even one exec `id -nG` anyway + setgrent(); + my %users; + while (my ($name, $passwd, $gid, $members) = getgrent()) { + foreach my $member (split / /, $members) { + push @{$_cache_get_user_groups{$member}}, $name; + } + } + setgrent(); + } + + my @groups = @{$_cache_get_user_groups{$user} || []}; + my @availableGroups; + foreach my $group (@groups) { + if ($group =~ /^key.+-(gatekeeper|aclkeeper|owner)$/) { + push @availableGroups, $group if $extra; + } + else { + push @availableGroups, $group if $group =~ /^key/; + } + } + + if (scalar(@availableGroups)) { + return R('OK', value => \@availableGroups); + } + else { + return R('ERR_NO_GROUP', msg => 'Unable to find any group'); + } +} + +sub _get_pub_keys_from_directory { + my %params = @_; + my $dir = $params{'dir'}; + my $pattern = $params{'pattern'}; + my $listOnly = $params{'listOnly'}; # don't open the files, just return file names + my $noexec = $params{'noexec'}; # passed to is_valid_public_key + my $forceKey = $params{'forceKey'}; + my $wantPrivate = $params{'wantPrivate'}; # if set, will return the fullpath of the private key, not the public one + my $fnret; + + osh_debug("looking for pub keys in dir $dir as user $ENV{'USER'}"); + if (!-d $dir) { + return R('ERR_DIRECTORY_NOT_FOUND', msg => "directory $dir doesn't exist"); + } + my $dh; + if (!opendir($dh, $dir)) { + return R('ERR_CANNOT_OPEN_DIRECTORY', msg => "can't open directory $dir: $!"); + } + + if (defined $pattern and ref $pattern ne 'Regexp') { + return R('ERR_INVALID_PARAMETER', msg => 'pattern is not a Regexp reference'); + } + + my %return; + while (my $file = readdir($dh)) { + $file =~ /^([a-zA-Z0-9._-]+\.pub)$/ or next; + $file = $1; # untaint + if (defined $pattern) { + $file =~ /$pattern/ or next; + } + my $filename = $file; + $file = "$dir/$file"; + -f -r $file or next; + + # ok file exists, is readable and matches the pattern + osh_debug("file $file matches the pattern in $dir"); + + my $mtime = (stat(_))[9]; + if ($listOnly) { + $return{$file} = {fullpath => $file, filename => $filename, mtime => $mtime}; + if ($wantPrivate) { + $return{$file}{'fullpath'} =~ s/\.pub$//; + $return{$file}{'filename'} =~ s/\.pub$//; + } + } + else { + # open the file and read the key + my $fh_key; + if (!open($fh_key, '<', $file)) { + osh_debug("can't open file $file ($!), skipping"); + next; + } + + while (my $line = <$fh_key>) { + + # stop when we find a key or at EOF + chomp $line; + $fnret = OVH::Bastion::is_valid_public_key(way => 'egress', pubKey => $line, noexec => ($noexec && !$forceKey)); + if (!$fnret) { + osh_debug("key in $file is not valid: " . $fnret->err); + osh_debug($fnret->msg); + } + else { + if ((not defined $forceKey) || ($forceKey eq $fnret->value->{'fingerprint'})) { + $return{$file} = $fnret->value; + $return{$file}{'fullpath'} = $file; + $return{$file}{'mtime'} = $mtime; + $return{$file}{'filename'} = $filename; + if ($wantPrivate) { + $return{$file}{'fullpath'} =~ s/\.pub$//; + $return{$file}{'filename'} =~ s/\.pub$//; + } + } + last; + } + } + close($fh_key); + } + } + close($dh); + + # return a sorted keys list too f(mtime) desc + my @sortedKeys = sort { $return{$b}{'mtime'} <=> $return{$a}{'mtime'} } keys %return; + return R('OK', value => {keys => \%return, sortedKeys => \@sortedKeys}); +} + +sub duration2human { + my %params = @_; + my $s = $params{'seconds'}; + my $tense = $params{'tense'}; + + require POSIX; + my $date = POSIX::strftime("%a %Y-%m-%d %H:%M:%S %Z", localtime(time() + ($tense eq 'past' ? -$s : $s))); + + my $d = int($s / 86400); + $s -= $d * 86400; + my $h = int($s / 3600); + $s -= $h * 3600; + my $m = int($s / 60); + $s -= $m * 60; + + my $duration = $d ? sprintf('%dd+%02d:%02d:%02d', $d, $h, $m, $s) : sprintf('%02d:%02d:%02d', $h, $m, $s); + return R('OK', value => {duration => $duration, date => $date, human => "$duration ($date)"}); +} + +sub print_acls { + my %params = @_; + my $acls = $params{'acls'} || []; + my $reverse = $params{'reverse'}; + my $hideGroups = $params{'hideGroups'}; + + my $printIpLen = $reverse ? 30 : 15; + osh_info( + sprintf("%-" . $printIpLen . "s %5s %20s %30s %10s %13s %45s %40s %s", "IP", "PORT", "USER", "ACCESS-BY", "ADDED-BY", "ADDED-AT", "EXPIRY?", "COMMENT", "FORCED-KEY?")); + + my @flatArray; + foreach my $contextAcl (@$acls) { + my $type = $contextAcl->{'type'}; + my $group = $contextAcl->{'group'}; + my $acl = $contextAcl->{'acl'}; + + next if ($hideGroups and $type =~ /^group/); + my $accessType = ($group ? "$group($type)" : $type); + + foreach my $entry (@$acl) { + my $addedBy = $entry->{'addedBy'} || '(unknown)'; + my $addedDate = $entry->{'addedDate'} || '(unknown)'; + $addedDate = substr($addedDate, 0, 10); + my $forceKey = $entry->{'forceKey'} || '-'; + my $expiry = $entry->{'expiry'} ? (duration2human(seconds => ($entry->{'expiry'} - time()))->value->{'human'}) : '-'; + + # type => member ('full'), guest ('partial'), personal or legacy + my $ipReverse = OVH::Bastion::ip2host($entry->{'ip'})->value if $reverse; + $entry->{'reverseDns'} = $ipReverse; + + push @flatArray, $entry; + osh_info( + sprintf( + "%-" . $printIpLen . "s %5s %20s %30s %10s %13s %45s %40s %s", + $ipReverse ? $ipReverse : $entry->{'ip'}, + $entry->{'port'} ? $entry->{'port'} : '(any)', + $entry->{'user'} ? $entry->{'user'} : '(any)', + $accessType, $addedBy, $addedDate, $expiry, $entry->{'userComment'} || '-', $forceKey + ) + ); + } + } + osh_info(scalar(@flatArray) . " accesses listed"); + return R('OK', value => \@flatArray); +} + +# checks if ip matches any given array of prefixes/networks +sub _is_in_any_net { + my %params = @_; + my $ip = $params{'ip'}; + my $networks = $params{'networks'}; + + if (!$ip) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'ip'"); + } + if (ref $networks ne 'ARRAY') { + return R('ERR_INVALID_PARAMETER', msg => "Parameter 'networks' must be an array"); + } + + foreach my $net (@$networks) { + if ($net =~ m{/}) { + + # build slash and test + require Net::Netmask; + my $ipCheck = Net::Netmask->new2($net); + return R('OK', value => {matched => $net}) if ($ipCheck && $ipCheck->match($ip)); + } + else { + # it's a single ip, so it's a stupid strcmp() does the trick + return R('OK', value => {matched => $net}) if ($net eq $ip); + } + } + return R('KO', msg => "No match found"); +} + +# this function checks if the given account has access to user@ip:port +# through any of the supported ways (personal/group/guest/legacy accesses), +# by calling is_access_way_granted() multiple times with the proper params. +# it can also add the fullpath of the keys to try for allowed accesses if asked to +# returns: arrayref of contextualized grants, contextualized-grant: { type, group, $granthashref } +# granthashref: returned by is_access_way_granted, i.e. { match, size, forceKey } +sub is_access_granted { + my %params = @_; + + # we'll use delete for params that we won't pass through is_access_way_granted() + my $account = delete $params{'account'}; # account to check the access grants of. + # can also be of the format "realm/remoteself" + + my $ipfrom = $params{'ipfrom'}; # must be an IP (client IP) + my $ip = $params{'ip'}; # can be a single IP or a slash + my $port = $params{'port'}; # if undef, means we look for a port wildcard allow + my $user = $params{'user'}; # if undef, means we look for a user wildcard allow + + my $listOnly = $params{'listOnly'}; # don't open the files, just return file names + my $noexec = $params{'noexec'}; # passed to is_valid_public_key + + my $wantKeys = delete $params{'wantKeys'}; # if set, look for and return ssh keys along with allowed accesses + + delete $params{'way'}; # WE specify this parameter, not our caller + delete $params{'group'}; # WE specify this parameter, not our caller + + my @grants; + my $fnret; + require Data::Dumper; + + # 0a/3 check if we're in a forbidden network. if we are, just bail out + my $forbiddenNetworks = OVH::Bastion::config('forbiddenNetworks')->value; + $fnret = _is_in_any_net(ip => $ip, networks => $forbiddenNetworks); + return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip as it's part of the forbidden networks of this bastion (see --osh info)") if $fnret->is_ok; + + # 0b/3 check if we're not outside of the bastion allowed networks, if we are, just bail out + my $allowedNetworks = OVH::Bastion::config('allowedNetworks')->value; + if (@$allowedNetworks) { + $fnret = _is_in_any_net(ip => $ip, networks => $allowedNetworks); + return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip as it's not part of the allowed networks of this bastion (see --osh info)") if $fnret->is_ko; + } + + # 0c/3 check if there are more complex "ingressToEgressRules" defined, and potentially bail out whether needed + $fnret = OVH::Bastion::config('ingressToEgressRules'); + my @rules = @{$fnret->value || []}; + for (my $ruleNb = 0 ; $ruleNb < @rules ; $ruleNb++) { + my ($inNets, $outNets, $policy) = @{$rules[$ruleNb]}; + + $fnret = _is_in_any_net(ip => $ipfrom, networks => $inNets); + if ($fnret->is_err) { + warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, ingress"); + return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!"); + } + + # ingress IP doesn't match for this rule, go to next: + next if $fnret->is_ko; + + # ingress IP matches, check whether egress IP matches + $fnret = _is_in_any_net(ip => $ip, networks => $outNets); + if ($fnret->is_err) { + warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, egress"); + return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!"); + } + if ($policy eq 'ALLOW-EXCLUSIVE') { + if ($fnret->is_ok) { + + # egress matches: allowed, stop checking more rules + last; + } + + # is_ko: we're in exclusive mode, stop checking and deny + return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip, as it's not part of the allowed networks given where you're connecting from ($ipfrom)"); + } + elsif ($policy eq 'DENY') { + if ($fnret->is_ok) { + + # egress matches: we have been asked to deny + return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip, as it's not part of the allowed networks given where you're connecting from ($ipfrom)"); + } + + # is_ko: egress doesn't match, check next rule + } + elsif ($policy eq 'ALLOW') { + if ($fnret->is_ok) { + + # egress matches: we have been asked to allow, stop checking more rules + last; + } + + # is_ko: egress doesn't match, check next rule + } + else { + # invalid policy + warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, policy"); + return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!"); + } + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + + # 1/3 check for personal accesses + # ... normal way + my $grantedPersonal = is_access_way_granted(%params, way => 'personal', account => $account); + osh_debug("is_access_granted: grantedPersonal=" . Data::Dumper::Dumper($grantedPersonal)); + push @grants, {type => 'personal', %{$grantedPersonal->value}} if $grantedPersonal; + + # ... legacy way + my $grantedLegacy = is_access_way_granted(%params, way => 'legacy', account => $account); + osh_debug("is_access_granted: grantedLegacy=" . Data::Dumper::Dumper($grantedLegacy)); + push @grants, {type => 'personal-legacy', %{$grantedLegacy->value}} if $grantedLegacy; + + # 2/3 check groups + $fnret = OVH::Bastion::get_user_groups(account => $sysaccount); + osh_debug("is_access_granted: get_user_groups of $sysaccount says " . $fnret->msg . " with grouplist " . Data::Dumper::Dumper($fnret->value)); + + foreach my $group (@{$fnret->value || []}) { + + # sanitize the group name + $fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key"); + $fnret or next; + $group = $fnret->value->{'group'}; # untaint + my $shortGroup = $fnret->value->{'shortGroup'}; + + # then check for group access + my $grantedGroup = is_access_way_granted(%params, way => "group", group => $shortGroup); + osh_debug("is_access_granted: grantedGroup=" . Data::Dumper::Dumper($grantedGroup)); + next if not $grantedGroup; # if group doesn't have access, don't even check legacy either + + # now we have to cases, if the group has access: either the account is member or guest + if (OVH::Bastion::is_group_member(group => $shortGroup, account => $account, sudo => $params{'sudo'})) { + + # normal member case, just reuse $grantedGroup + osh_debug("is_access_granted: adding grantedGroup to grants because is member"); + push @grants, {type => 'group-member', group => $shortGroup, %{$grantedGroup->value}}; + } + elsif (OVH::Bastion::is_group_guest(group => $shortGroup, account => $account, sudo => $params{'sudo'})) { + + # normal guest case + my $grantedGuest = is_access_way_granted(%params, way => "groupguest", group => $shortGroup, account => $account); + osh_debug("is_access_granted: grantedGuest=" . Data::Dumper::Dumper($grantedGuest)); + + # the guy must have a guest access but the group itself must also still have access + if ($grantedGuest && $grantedGroup) { + push @grants, {type => 'group-guest', group => $shortGroup, %{$grantedGuest->value}}; + osh_debug("is_access_granted: adding grantedGuest to grants because is guest and group has access"); + } + + # special legacy case; we also check if account has a legacy access for ip AND that the group ALSO has access to this ip + if ($grantedLegacy && $grantedGroup) { + osh_debug("is_access_granted: adding grantedLegacy to grants because legacy not null and group has access"); + push @grants, {type => 'group-guest-legacy', group => $shortGroup, %{$grantedLegacy->value}}; + } + } + else { + # should not happen + osh_debug("is_access_granted: $account is in group $shortGroup but is neither member or guest !!?"); + } + } + + # 3/3 fill up keys if asked to + if ($wantKeys) { + foreach my $access (@grants) { + undef $fnret; + my $mfaFnret; + if ($access->{'type'} =~ /^group/ and $access->{'group'}) { + $fnret = OVH::Bastion::get_group_keys(group => $access->{'group'}, listOnly => $listOnly, noexec => $noexec, forceKey => $access->{'forceKey'}); + $mfaFnret = OVH::Bastion::group_config(key => "mfa_required", group => $access->{'group'}); + } + elsif ($access->{'type'} =~ /^personal/) { + $fnret = OVH::Bastion::get_personal_account_keys(account => $sysaccount, listOnly => $listOnly, noexec => $noexec, forceKey => $access->{'forceKey'}); + $mfaFnret = OVH::Bastion::account_config(key => "personal_egress_mfa_required", account => $sysaccount); + } + else { + ; # unknown access type? no key! + } + if ($fnret) { + + # TODO implement $access->{forceKey} check to include only the proper key + $access->{'keys'} = $fnret->value->{'keys'}; + $access->{'sortedKeys'} = $fnret->value->{'sortedKeys'}; + $access->{'mfaRequired'} = $mfaFnret->value if $mfaFnret; + } + } + } + + return R('OK', value => \@grants) if @grants; + + my $machine = $ip; + $machine .= ":$port" if $port; + $machine = $user . '@' . $machine if $user; + return R('KO_ACCESS_DENIED', msg => "Access denied for $account to $machine"); +} + +sub ssh_test_access_way { + my %params = @_; + my $account = $params{'account'}; + my $group = $params{'group'}; + + my $port = $params{'port'}; + my $ip = $params{'ip'}; + my $user = $params{'user'}; + my $fnret; + + if (defined $account and defined $group) { + return R('ERR_INCOMPATIBLE_PARAMETERS'); + } + + $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); + $fnret or return $fnret; + if ($fnret->value->{'type'} eq 'prefix') { + return R('OK_PREFIX', msg => "Can't test a connection to a prefix, assuming it's OK"); + } + $ip = $fnret->value->{'ip'}; + + if ($port) { + $fnret = OVH::Bastion::is_valid_port(port => $port); + $fnret or return $fnret; + $port = $fnret->value; + } + + $user = OVH::Bastion::config("defaultLogin")->value if not $user; + $user = $account if not $user; # defaultLogin empty means the user himself + $user = OVH::Bastion::get_user_from_env()->value if not $user; # no user or account ? get from env then + $fnret = OVH::Bastion::is_valid_remote_user(user => $user); + $fnret or return $fnret; + $user = $fnret->value; + + if ($group) { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); + $fnret or return $fnret; + my $shortGroup = $fnret->value->{'shortGroup'}; + $group = $fnret->value->{'group'}; + + $fnret = OVH::Bastion::get_group_keys(group => $shortGroup); + } + elsif ($account) { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; + + $fnret = OVH::Bastion::get_personal_account_keys(account => $account); + } + else { + return R('ERR_MISSING_PARAMETER', msg => "Missing 'group' or 'account' for ssh_test_access_way"); + } + $fnret or return $fnret; + + my @keyList; + foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + my $privkey = $key->{'fullpath'}; + $privkey =~ s/\.pub$//; + push @keyList, $privkey if -r $privkey; + } + + if (not @keyList) { + return R('OK_NO_KEYS_TO_TEST', + msg => +"Couldn't find any accessible SSH key to test connection with, you're probably adding access to an account or a group you don't have access to yourself, nevermind, will continue" + ); + } + + if ($user eq '!scpupload' || $user eq '!scpdownload') { + return R('OK_MAGIC_USER', msg => "Didn't really test the connection, as the specified user is special"); + } + + my $preferredAuthentications = 'publickey'; + $preferredAuthentications .= ',keyboard-interactive' if $ENV{'OSH_KBD_INTERACTIVE'}; + + # ssh -i with the correct keys + # UserKnownHostsFile/StrictHostKeyChecking: avoid problem when opening /dev/tty under sudo + my @command = qw{ ssh -o ConnectTimeout=5 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no }; + push @command, '-o', 'PreferredAuthentications=' . $preferredAuthentications; + foreach (@keyList) { + push @command, "-i", $_; + } + if (!OVH::Bastion::is_openbsd()) { + unshift @command, qw{ timeout -k 1 6 }; + } + + # add port when specified + push @command, ("-p", $port) if $port; + + push @command, "-l", $user, $ip, '-T', '--', 'true'; + + osh_info("Testing connection to $user\@$ip, please wait..."); + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1); + $fnret or return $fnret; + + no strict 'subs'; + if (grep { $fnret->value->{'sysret'} eq $_ } (0, OVH::Bastion::EXIT_ACCOUNT_INVALID, OVH::Bastion::EXIT_HOST_NOT_FOUND)) { + return R('OK'); + } + use strict 'subs'; + + my $hint; + + # 124 is the return code from the timeout system command when it times out + # tested on Linux, NetBSD + if ($fnret->value->{'sysret'} == 124 || grep { $_ =~ /timed out/i } @{$fnret->value->{'stderr'} || []}) { + $hint = "Hint: did you remotely allow this bastion to access the SSH port?"; + } + elsif (grep { $_ =~ /Permission denied/i } @{$fnret->value->{'stderr'} || []}) { + $hint = "Hint: did you add the proper public key to the remote's authorized_keys?"; + } + my $msg = "Couldn't connect to $user\@$ip (ssh returned error " . $fnret->value->{'sysret'} . ")"; + $msg .= ". $hint" if defined $hint; + + return R('ERR_CONNECTION_FAILED', msg => $msg); +} + +# get all accesses from an account, by any way possible +# returns: arrayref of contextualized acls, contextualized-acl: { type, group, \@aclentries } +sub get_acls { + my %params = @_; + my $account = $params{'account'}; + + my @acls; + my $fnret; + require Data::Dumper; + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + + # 1/3 check for personal accesses + # ... normal way + my $grantedPersonal = OVH::Bastion::get_acl_way(way => 'personal', account => $account); + osh_debug("get_acls: grantedPersonal=" . Data::Dumper::Dumper($grantedPersonal)); + push @acls, {type => 'personal', acl => $grantedPersonal->value} if ($grantedPersonal && @{$grantedPersonal->value}); + + # ... legacy way + my $grantedLegacy = OVH::Bastion::get_acl_way(way => 'legacy', account => $account); + osh_debug("get_acls: grantedLegacy=" . Data::Dumper::Dumper($grantedLegacy)); + push @acls, {type => 'personal-legacy', acl => $grantedLegacy->value} if ($grantedLegacy && @{$grantedLegacy->value}); + + # 2/3 check groups + $fnret = OVH::Bastion::get_user_groups(account => $sysaccount); + osh_debug("get_acls: get_user_groups of $sysaccount says " . $fnret->msg . " with grouplist " . Data::Dumper::Dumper($fnret->value)); + + foreach my $group (@{$fnret->value || []}) { + + # sanitize the group name + $fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key"); + $fnret or next; + $group = $fnret->value->{'group'}; # untaint + my $shortGroup = $fnret->value->{'shortGroup'}; + + # then check for group access + my $grantedGroup = OVH::Bastion::get_acl_way(way => "group", group => $shortGroup); + osh_debug("get_acls: grantedGroup=" . Data::Dumper::Dumper($grantedGroup)); + next if not $grantedGroup; # if group doesn't have access, don't even check legacy either + + # now we have to cases, if the group has access: either the account is member or guest + if (OVH::Bastion::is_group_member(group => $shortGroup, account => $account)) { + + # normal member case, just reuse $grantedGroup + osh_debug("get_acls: adding grantedGroup to grants because is member"); + push @acls, {type => 'group-member', group => $shortGroup, acl => $grantedGroup->value} if ($grantedGroup && @{$grantedGroup->value}); + } + elsif (OVH::Bastion::is_group_guest(group => $shortGroup, account => $account)) { + + # normal guest case + my $grantedGuest = OVH::Bastion::get_acl_way(way => "groupguest", group => $shortGroup, account => $account); + osh_debug("get_acls: grantedGuest=" . Data::Dumper::Dumper($grantedGuest)); + + # the guy must have a guest access but the group itself must also still have access + if ($grantedGuest && $grantedGroup) { + osh_debug("get_acls: adding grantedGuest to grants because is guest and group has access"); + push @acls, {type => 'group-guest', group => $shortGroup, acl => $grantedGuest->value} if @{$grantedGuest->value}; + } + + # special legacy case; we also check if account has a legacy access for ip AND that the group ALSO has access to this ip + if ($grantedLegacy && $grantedGroup) { + osh_debug("get_acls: adding grantedLegacy to grants because legacy not null and group has access"); + push @acls, {type => 'group-guest-legacy', group => $shortGroup, acl => $grantedLegacy->value} if @{$grantedLegacy->value}; + } + } + else { + # should not happen + osh_debug("get_acls: $account is in group $shortGroup but is neither member or guest !!?"); + } + } + return R('OK', value => \@acls); +} + +# this function simply returns the requested acl +# i.e. personal or legacy access of an account, group access, or groupguest access. +# it just calls get_acl_from_file() with the proper file location +# returns: arrayref of entries, entry: { ip,user,port,forceKey,addedBy,addedDate,comment } +my %_cache_get_acl_way; + +sub get_acl_way { + my %params = @_; + my $way = delete $params{'way'}; # personal|group|groupguest|legacy + my $group = delete $params{'group'}; # only meaningful and needed if type=group or type=groupguest + my $account = delete $params{'account'}; # only meaningful and needed if type=personal or type=groupguest + + my $fnret; + my ($sysaccount, $remoteaccount); + my $key = $way; + my $prefix = 'allowed'; + + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'way'") if not defined $way; + + if ($account) { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; + $sysaccount = $fnret->value->{'sysaccount'}; + $remoteaccount = $fnret->value->{'remoteaccount'}; + $prefix = "allowed_$remoteaccount" if $remoteaccount; + $key .= ":$account"; + } + + my $shortGroup; + if ($group) { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or return $fnret; + $group = $fnret->value->{'group'}; # untainted version + $shortGroup = $fnret->value->{'shortGroup'}; + $key .= ":$group"; + } + + return $_cache_get_acl_way{$key} if exists $_cache_get_acl_way{$key}; + + if ($way eq 'personal') { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' for $way way") if not $account; + if (OVH::Bastion::is_mocking()) { + return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_personal_accesses(account => $account)); + } + if (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.private")) { + return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist"); + } + $_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.private"); + } + elsif ($way eq 'legacy') { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' for $way way") if not $account; + if (OVH::Bastion::is_mocking()) { + return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_legacy_accesses(account => $account)); + } + if (-f "/home/allowkeeper/$sysaccount/$prefix.private" && !-e "/home/allowkeeper/$sysaccount/$prefix.ip") { + + # legacy file doesn't exist: no legacy rights + $_cache_get_acl_way{$key} = R('OK_EMPTY', value => []); + } + elsif (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.ip")) { + return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist"); + } + else { + $_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.ip"); + } + } + elsif ($way eq 'group') { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group' for $way way") if not $group; + if (OVH::Bastion::is_mocking()) { + return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_group_accesses(group => $group)); + } + if (!(-f -r "/home/$group/$prefix.ip")) { + return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist"); + } + $_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/$group/$prefix.ip"); + } + elsif ($way eq 'groupguest') { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' or 'group' for $way way") if (not $group or not $account); + if (OVH::Bastion::is_mocking()) { + return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_guest_accesses(group => $group, account => $account)); + } + if (-f "/home/allowkeeper/$sysaccount/$prefix.private" && !-e "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup") { + + # guest file doesn't exist: no guest rights + $_cache_get_acl_way{$key} = R('OK_EMPTY', value => []); + } + elsif (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup")) { + return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist"); + } + else { + $_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup"); + } + } + + return $_cache_get_acl_way{$key} if exists $_cache_get_acl_way{$key}; + return R('ERR_INVALID_PARAMETER', msg => "Expected a parameter way with allowed values [personal,legacy,group,groupguest]"); +} + +# returns the parsed contents of an allowkeeper-style file +sub _get_acl_from_file { + my %params = @_; + my $file = $params{'file'}; + my $mock_data = $params{'mock_data'}; + + my $fnret; + my @lines; + + if ($mock_data) { + die "attempted to mock_data outside of mocking" if !OVH::Bastion::is_mocking(); + @lines = @$mock_data; + } + else { + osh_debug("Reading ACL from '$file'"); + + if (not $file) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'file'"); + } + if (!(-e $file)) { + return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' doesn't exist"); + } + if (!(-r _)) { + return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' is not readable"); + } + + if (open(my $fh_file, '<', $file)) { + @lines = <$fh_file>; + close($fh_file); + chomp @lines; + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Can't open '$file' for read ($!)"); + } + } + + my @entries; + foreach my $line (@lines) { + my ($ip, $user, $port, $comment, $forceKey, $expiry, $addedBy, $addedDate, $extra, $comment, $userComment); + + # extract comment if any + $line =~ s/(#.*)// and $comment = $1; + + # remove white spaces + $line =~ s/\s//g; + + # empty line ? + $line or next; + + # extract custom port if present + if ($line =~ s/:(\d+)$//) { + $fnret = OVH::Bastion::is_valid_port(port => $1); + if (!$fnret) { + osh_debug("skipping line <$line> because port ($1) is invalid"); + next; + } + $port = $fnret->value; + } + + # extract custom user if present + if ($line =~ s/^(\S+)\@//) { + $fnret = OVH::Bastion::is_valid_remote_user(user => $1); + if (!$fnret) { + osh_debug("skipping line <$line> because user ($1) is invalid"); + next; + } + $user = $fnret->value; + } + + # extract ip (v4 or v6) + if ($line =~ m{([0-9a-f./:]+)}i) { + $fnret = OVH::Bastion::is_valid_ip(ip => $1, allowPrefixes => 1, fast => 1); + if (!$fnret) { + osh_debug("skipping line <$line> because IP ($1) is invalid"); + next; + } + $ip = $fnret->value->{'ip'}; + } + else { + osh_debug("skipping line <$line> because no valid IP found"); + next; + } + + # if we have a comment, there might be stuff to extract from it + if (defined $comment) { + osh_debug("Parsing comment ($comment)"); + if ($comment =~ s/# EXPIRY=(\d+)//) { + if ($1 < time()) { + osh_debug("found an expired line <$line>, skipping it"); + next; + } + $expiry = $1 + 0; + } + if ($comment =~ s/# FORCEKEY=(\S+)//) { + $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $1); + if (!$fnret) { + osh_debug("skipping line <$line> because invalid forcekey fingerprint ($1) found"); + next; + } + $forceKey = $fnret->value->{'fingerprint'}; + osh_debug("found a valid forced key <$forceKey>"); + } + if ($comment =~ s/# COMMENT=<([^>]+)>//) { + $userComment = $1; + } + if ($comment =~ s/# add(ed)? by (\S+) on (\S+ \S+)//) { + $addedBy = $2; + $addedDate = $3; + } + $comment !~ /^\s*$/ and $extra = $comment; + } + + push @entries, + { + ip => $ip, + user => $user, + port => $port, + forceKey => $forceKey, + expiry => $expiry, + addedBy => $addedBy, + addedDate => $addedDate, + userComment => $userComment, + comment => $extra, + }; + } + + osh_debug("found " . (scalar @entries) . " valid entries"); + return R(@entries ? 'OK' : 'OK_EMPTY', value => \@entries); +} + +1; diff --git a/lib/perl/OVH/Bastion/allowkeeper.inc b/lib/perl/OVH/Bastion/allowkeeper.inc new file mode 100644 index 0000000..bdf962a --- /dev/null +++ b/lib/perl/OVH/Bastion/allowkeeper.inc @@ -0,0 +1,1002 @@ +package OVH::Bastion; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: + +use common::sense; + +use Time::Piece; # $t->strftime + +# Check if user belongs to a specific group +sub is_user_in_group { + my %params = @_; + my $group = $params{'group'}; + my $user = $params{'user'} || OVH::Bastion::get_user_from_env()->value; + + # mandatory keys + if (!$user or !$group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'user' or 'group'"); + } + + # get group info + my ($groupname, $passwd, $gid, $members) = getgrnam($group); + my @membersList = split / /, $members; + + if (grep { $user eq $_ } @membersList) { + return R('OK', value => {account => $user}); + } + return R('KO_NOT_IN_GROUP', msg => "Account $user doesn't belong to the group $group"); +} + +# does this group exist ? +sub is_group_existing { + my %params = @_; + my $group = $params{'group'}; + + if (!$group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group'"); + } + + my ($groupname, $password, $gid, $members) = getgrnam($group); + if ($groupname) { + my (undef, $shortGroup) = $group =~ m{^(key)?(.+)}; + return R('OK', value => {group => $group, shortGroup => $shortGroup, gid => $gid, members => [split(/ /, $members)]}); + } + return R('KO_GROUP_NOT_FOUND', msg => "Group '$group' doesn't exist"); +} + +# validate uid/gid +sub is_valid_uid { + my %params = @_; + my $uid = $params{'uid'}; + my $type = $params{'type'}; + + # Basic input validation + if ($uid !~ m/^\d+$/) { + return R('ERR_INVALID_PARAMETER', msg => "Parameter 'uid' should be numeric"); + } + + if ($type ne 'user' and $type ne 'group') { + return R('ERR_INVALID_PARAMETER', msg => "Parameter 'type' is invalid"); + } + + # Input validation against configuration + my $fnret = OVH::Bastion::load_configuration(); + $fnret or return $fnret; + + my ($accountUidMin, $accountUidMax, $ttyrecGroupIdOffset) = + @{$fnret->value}{qw{ accountUidMin accountUidMax ttyrecGroupIdOffset }}; + + if (not $accountUidMin or not $accountUidMax or not $ttyrecGroupIdOffset) { + return R('ERR_CANNOT_LOAD_CONFIGURATION'); + } + + my ($low, $high) = ($accountUidMin, $accountUidMax); + + if ($type eq 'group') { + $high += $ttyrecGroupIdOffset; + } + + if ($uid < $low or $uid > $high) { + return R('KO_BAD_RANGE', msg => "Parameter 'uid' should be between $low and $high"); + } + + $uid =~ m/^(\d+)$/; # Untaint + + return R('OK', value => $1); +} + +sub get_next_available_uid { + my %params = @_; + + my $higher = OVH::Bastion::config('accountUidMax')->value(); + my $lower = OVH::Bastion::config('accountUidMin')->value(); + my $next = $higher; + while ($next >= $lower) { + last if not scalar(getpwuid($next)); + $next--; + } + return R('OK', value => $next) if not scalar(getpwuid($next)); + return R('ERR_UID_COLLISION', msg => "No available UID in the allowed range"); +} + +sub is_bastion_account_valid_and_existing { + my %params = @_; + my $fnret = OVH::Bastion::is_account_valid(%params); + $fnret or return $fnret; + my %values = %{$fnret->value()}; + my ($account, $realm, $sysaccount, $remoteaccount) = @values{qw{ account realm sysaccount remoteaccount}}; + $fnret = OVH::Bastion::is_account_existing(account => $sysaccount, checkBastionShell => 1); + $fnret or return $fnret; + $fnret->value->{'account'} = $account; + $fnret->value->{'sysaccount'} = $sysaccount; + $fnret->value->{'realm'} = $realm; + $fnret->value->{'remoteaccount'} = $remoteaccount; + return $fnret; +} + +# check if account name is valid, i.e. non-weird chars and non reserved parts +sub is_account_valid { + my %params = @_; + my $account = $params{'account'}; + my $accountType = + $params{'accountType'} || 'normal'; # normal (local account or $realm/$remoteself formatted account) | group (must start with key*) | realm (must start with realm_*) + my $localOnly = $params{'localOnly'}; # for accountType == normal, disallow realm-formatted accounts ($realm/$remoteself) + my $realmOnly = $params{'realmOnly'}; # for accountType == normal, allow only realm-formatted accounts ($realm/$remoteself) + + if (!$account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); + } + + my $whatis = ($accountType eq 'realm' ? "Realm" : "Account"); + + if ($localOnly && $account =~ m{/}) { + return R('KO_REALM_FORBIDDEN', msg => "$whatis name must not contain any '/'"); + } + elsif ($realmOnly && $account !~ m{/}) { + return R('KO_LOCAL_FORBIDDEN', msg => "$whatis name must contain a '/'"); + } + elsif ($account =~ m/-tty$/i) { + return R('KO_FORBIDDEN_SUFFIX', msg => "$whatis name contains an unauthorized suffix"); + } + elsif ($account =~ m/^key/i && $accountType ne 'group') { + return R('KO_FORBIDDEN_PREFIX', msg => "$whatis name contains an unauthorized key prefix"); + } + elsif ($account !~ m/^key/i && $accountType eq 'group') { + return R('KO_BAD_PREFIX', msg => "$whatis should start with the group prefix"); + } + elsif ($account =~ m/^realm_/ && $accountType ne 'realm') { + return R('KO_FORBIDDEN_PREFIX', msg => "$whatis name contains an unauthorized realm prefix"); + } + elsif ($account !~ m/^realm_/ && $accountType eq 'realm') { + return R('KO_BAD_PREFIX', msg => "$whatis should start with the realm prefix"); + } + elsif (grep { $account eq $_ } qw{ root proxyhttp keykeeper passkeeper logkeeper realm realm_realm }) { + return R('KO_FORBIDDEN_NAME', msg => "$whatis name is reserved"); + } + elsif ($account =~ m{^([a-zA-Z0-9-]+)/([a-zA-Z0-9._-]+)$} && $accountType eq 'normal') { + if (length("realm_$1") > 18) { + return R('KO_TOO_LONG', msg => "$whatis name is too long, length(realm_$1) > 18"); + } + elsif (length($1) < 2) { + return R('KO_TOO_SMALL', msg => "$whatis name is too long, length($1) < 2"); + } + elsif (length($2) > 18) { + return R('KO_TOO_LONG', msg => "Remote account name is too long, length($2) > 18"); + } + elsif (length($2) < 2) { + return R('KO_TOO_SMALL', msg => "Remote account name is too short, length($2) < 2"); + } + return R('OK', value => {sysaccount => "realm_$1", realm => $1, remoteaccount => $2, account => "$1/$2"}); # untainted + } + elsif ($account =~ m/^([a-zA-Z0-9._-]+)$/) { + if (length($1) < 2) { + return R('KO_TOO_SMALL', msg => "$whatis name is too small, length($1) < 2"); + } + elsif (length($1) > 18) { + return R('KO_TOO_LONG', msg => "$whatis name is too long, length($1) > 18"); + } + return R('OK', value => {sysaccount => $1, realm => undef, remoteaccount => undef, account => $1}); # untainted + } + else { + return R('KO_FORBIDDEN_CHARS', msg => "$whatis name contains forbidden characters $account"); + } + return R('ERR_IMPOSSIBLE_CASE'); +} + +# check that this account is present on the bastion +# it also returns untainted data, including splitting by realm where applicable +sub is_account_existing { + my %params = @_; + my $account = $params{'account'}; + my $checkBastionShell = $params{'checkBastionShell'}; # check if this account is a bastion user + + if (!$account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); + } + + my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell, $expire) = getpwnam($account); + if (OVH::Bastion::is_mocking()) { + ($name, $passwd, $uid, $gid, $gcos, $dir, $shell) = OVH::Bastion::mock_get_account_entry(account => $account); + } + if ($name) { + my ($newname) = $name =~ m{([a-zA-Z0-9._-]+)}; + return R('ERR_SECURITY_VIOLATION', msg => "Forbidden characters in account name") if ($newname ne $name); + $name = $newname; # untaint + + if ($checkBastionShell && $shell ne $OVH::Bastion::BASEPATH . "/bin/shell/osh.pl") { + return R('KO_NOT_FOUND', msg => "Account '$account' doesn't exist"); # msg is the same as below, voluntarily + } + + my ($newdir) = $dir =~ m{([/a-zA-Z0-9._-]+)}; # untaint + return R('ERR_SECURITY_VIOLATION', msg => "Forbidden characters in account home directory") if ($newdir ne $dir); + $dir = $newdir; # untaint + return R('OK', value => {uid => $uid, gid => $gid, dir => $dir, account => $name}); + } + return R('KO_NOT_FOUND', msg => "Account '$account' doesn't exist"); +} + +# all ACL modifications (on groups, on accounts, including group-guests) are handled here +sub access_modify { + my %params = @_; + + my $action = $params{'action'}; # add or del + + my $user = $params{'user'}; # if undef, means a user-wildcard access + my $ip = $params{'ip'}; # can be a single ip or prefix + my $port = $params{'port'}; # if undef, means a port-wildcard access + + my $ttl = $params{'ttl'}; + + my $way = $params{'way'}; # group, groupguest, personal + my $group = $params{'group'}; # only for way=group or way=groupguest + my $account = $params{'account'}; # only for way=personal + + my $forceKey = $params{'forceKey'}; + my $comment = $params{'comment'}; + + my $fnret; + + foreach my $mandatoryParam (qw/action ip way/) { + if (!$params{$mandatoryParam}) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter '$mandatoryParam'"); + } + } + + # due to how plugins work, sometimes user and port are just '', make them undef in those cases + undef $user if (defined $user && $user eq ''); + undef $port if (defined $port && $port eq ''); + + # check way + if ($way eq 'personal') { + return R('ERR_INVALID_PARAMETER', msg => "Group parameter specified with way=personal") if defined $group; + return R('ERR_MISSING_PARAMETER', msg => "Account parameter mandatory with way=personal") if not defined $account; + } + elsif ($way eq 'group') { + return R('ERR_MISSING_PARAMETER', msg => "Group parameter mandatory with way=group") if not defined $group; + return R('ERR_INVALID_PARAMETER', msg => "Account parameter specified with way=group") if defined $account; + } + elsif ($way eq 'groupguest') { + if (not defined $account or not defined $group) { + return R('ERR_MISSING_PARAMETER', msg => "Account or group parameter missing with way=groupguest"); + } + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Parameter 'way' must be either personal, group or groupguest"); + } + + if ($action ne 'add' and $action ne 'del') { + return R('ERR_INVALID_PARAMETER', msg => "Action should be either 'del' or 'add'"); + } + + # check ip + $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); + return $fnret unless $fnret; + $ip = $fnret->value->{'ip'}; + + # check port + if (defined $port) { + $fnret = OVH::Bastion::is_valid_port(port => $port); + return $fnret unless $fnret; + $port = $fnret->value; + } + + # check remote user + if (defined $user) { + $fnret = OVH::Bastion::is_valid_remote_user(user => $user); + return $fnret unless $fnret; + $user = $fnret->value; + } + + # check account + my ($remoteaccount, $sysaccount); + if (defined $account) { + + # accountType==normal : account must NOT be a realm_* account (but can be a realm/jdoe account) + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => 'normal'); + $fnret or return $fnret; + $sysaccount = $fnret->value->{'sysaccount'}; + $account = $fnret->value->{'account'}; + $remoteaccount = $fnret->value->{'remoteaccount'}; + } + + # check group + my $shortGroup; + if (defined $group) { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or return $fnret; + $group = $fnret->value->{'group'}; # untainted + $shortGroup = $fnret->value->{'shortGroup'}; # untainted + } + + # check key fingerprint + if ($forceKey) { + $fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $forceKey); + $fnret or return $fnret; + $forceKey = $fnret->value->{'fingerprint'}; + } + + if ($ttl) { + return R('ERR_INVALID_PARAMETER', msg => "The TTL must be numeric") if ($ttl !~ /^(\d+)$/); + $ttl = $1; + } + + # check if the caller has the right to make the change he's asking + # ... 1. either $> is allowkeeper and $ENV{'SUDO_USER'} is the requesting account + # ... 2. or $> is $grouptomodify and $ENV{'SUDO_USER'} is the requesting account + + my ($running_as) = (getpwuid($>))[0] =~ /([0-9a-zA-Z_.-]+)/; + my ($requester) = $ENV{'SUDO_USER'} =~ /([0-9a-zA-Z_.-]+)/; + + # requester can never be a realm_* account, because it's shared and should not be able to add access to anything + return R('ERR_SECURITY_VIOLATION', msg => "Requester can't be a realm user") if $requester =~ /^realm_/; + + my @one_should_succeed; + my $expected_running_as = 'allowkeeper'; + + if ($way eq 'personal') { + if ($requester eq $account) { + push @one_should_succeed, OVH::Bastion::is_user_in_group(user => $requester, group => 'osh-self' . ucfirst($action) . 'PersonalAccess', sudo => 1); + } + + # this is not a else here: somebody who has the account* right doesn't need the self* right + push @one_should_succeed, OVH::Bastion::is_user_in_group(user => $requester, group => 'osh-account' . ucfirst($action) . 'PersonalAccess', sudo => 1); + } + elsif ($way eq 'group') { + $expected_running_as = "$group-aclkeeper"; + push @one_should_succeed, OVH::Bastion::is_group_aclkeeper(account => $requester, group => $shortGroup, superowner => 1, sudo => 1); + } + elsif ($way eq 'groupguest') { + push @one_should_succeed, OVH::Bastion::is_group_gatekeeper(account => $requester, group => $shortGroup, superowner => 1, sudo => 1); + } + + if (not defined $expected_running_as || $running_as ne $expected_running_as) { + return R('ERR_SECURITY_VIOLATION', msg => "Current running user unexpected"); + } + + if (grep({ $_ } @one_should_succeed) == 0 && $requester ne 'root') { + return R('ERR_SECURITY_VIOLATION', msg => "You're not allowed to do that"); + } + + # now, check if the access we're being asked to change is already in place or not + osh_debug("for action $action of $user\@$ip:$port of way $way with account=$account and group=$group, checking if already granted"); + $fnret = OVH::Bastion::is_access_way_granted( + user => $user, + ip => $ip, + port => $port, + way => $way, + group => $shortGroup, + account => $account, + exactMatch => 1, # we're checking if the exact right we're asked to modify exists or not + ); + osh_debug("... result is $fnret"); + + #return $fnret if $fnret->is_err; + + if ($action eq 'add' and $fnret) { + return R('OK_NO_CHANGE', msg => "The requested access to add was already granted"); + } + elsif ($action eq 'del' and not $fnret) { + return R('OK_NO_CHANGE', msg => "The requested access to delete was not found, no change made"); + } + + # TODO for groupguest case, also check that the group has the right + + # ok, now do the change, first define this sub + + my $_access_modify_file = sub { + my %sub_params = @_; + my $file = $sub_params{'file'}; + + # we don't check our params or the rights because our caller already did, guaranteed by the scoping of this sub + + # check if we can access the file + if (!(-e $file)) { + + # it doesn't exist yet, create it + OVH::Bastion::touch_file($file, 0644); + if (!(-e $file)) { + return R('ERR_CANNOT_CREATE_FILE', msg => "File '$file' is missing and couldn't be created"); + } + } + + # can we write to it ? + if (!(-w $file)) { + return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' cannot be written to"); + } + + # build the line we're either adding or looking for (to delete it) + my $entry = $ip; + $entry = $user . "@" . $entry if defined $user; + $entry = $entry . ":" . $port if defined $port; + my $machine = $entry; + + my $t = localtime(time); + my $fmt = "%Y-%m-%d %H:%M:%S"; + my $date = $t->strftime($fmt); + my $entryComment = "# $action by $requester on $date"; + + # if we're adding it, add comment and potential FORCEKEY + if ($action eq 'add') { + + $entry .= " $entryComment"; + + if ($forceKey) { + + # hash is case-sensitive only for new SHA256 format + $forceKey = lc($forceKey) if ($forceKey !~ /^sha256:/i); + $entry .= " # FORCEKEY=" . $forceKey; + } + if ($ttl) { + $entry .= " # EXPIRY=" . (time() + $ttl); + } + if ($comment) { + $comment =~ s{[#<>\\"']}{_}g; + $entry .= " # COMMENT=<" . $comment . ">"; + } + } + + # to be extra sure, remove any \n in $entry, which is impossible because we vetted all the params, + # but if somehow we failed, we'll be sure it doesn't permit to add multiple rights at once + $entry =~ s/[\r\n]*//gm; + + # now, do the change + my $returnmsg; + if ($action eq 'add') { + osh_debug("going to add entry '$entry'"); + if (open(my $fh_file, '>>', $file)) { + print $fh_file $entry . "\n"; + close($fh_file); + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Error opening $file: $!"); + } + my $ttlmsg = $ttl ? (' (expires in ' . OVH::Bastion::duration2human(seconds => $ttl)->value->{'human'} . ')') : ''; + $returnmsg = "Access to $machine successfully added$ttlmsg"; + } + elsif ($action eq 'del') { + if (open(my $fh_file, '<', $file)) { + my $newFile; + my $found = 0; + while (my $line = <$fh_file>) { + if ($line =~ m{^\Q$entry\E(\s|$)}) { + chomp $line; + $line = "# $line # $comment\n"; + $found++; + } + $newFile .= $line; + } + close($fh_file); + + if ($found) { + + # now rewrite + if (open(my $fh_file, '>', $file)) { + print $fh_file $newFile; + close($fh_file); + $returnmsg = "Access to $machine successfully removed"; + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Unable to write open $file"); + } + } + else { + return R('OK_NO_CHANGE', msg => "Entry $entry was not present in file $file"); + } + } + } + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'acl', + fields => [ + ['action', $params{'action'}], + ['type', $params{'way'}], + ['group', $shortGroup], + ['account', $params{'account'}], + ['user', $params{'user'}], + ['ip', $params{'ip'}], + ['port', $params{'port'}], + ['ttl', $params{'ttl'}], + ['force_key', $params{'forceKey'}], + ['comment', $params{'comment'}], + ] + ); + return R('OK', msg => $returnmsg) if $returnmsg; + return R('ERR_INTERNAL'); + }; # end of sub definition + + # then call the sub we just defined + delete $params{'file'}; + my $ret; + my $prefix = $remoteaccount ? "allowed_$remoteaccount" : "allowed"; + if ($way eq 'personal') { + $ret = $_access_modify_file->(%params, file => "/home/allowkeeper/$sysaccount/$prefix.private"); + } + elsif ($way eq 'group') { + $ret = $_access_modify_file->(%params, file => "/home/$group/allowed.ip"); + } + elsif ($way eq 'groupguest') { + $ret = $_access_modify_file->(%params, file => "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup"); + } + osh_debug("_access_modify_file() said $ret"); + return $ret if defined $ret; + + return R('ERR_INTERNAL'); # unreachable +} + +# Check that a group is valid or not (syntax) +sub is_valid_group { + my %params = @_; + my $group = $params{'group'}; + my $groupType = $params{'groupType'}; + + # osh: osh-accountListBastionKeys + # tty: login8-tty + # key: keymygroup + # gatekeeper: keymygroup-gatekeeper + # aclkeeper: keymygroup-aclkeeper + # owner: keymygroup-owner + + if (!$group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group'"); + } + + # autodetect if my caller prefixed the group name with 'key' or not, and adjust accordingly. + # we'll return normalized group and shortGroup values to our caller + if ($group !~ /^key/ && defined $groupType && grep { $groupType eq $_ } qw{ key gatekeeper aclkeeper owner }) { + $group = "key$group"; + } + + if ($group =~ m/keeper$/i and not grep { $groupType eq $_ } qw{ gatekeeper aclkeeper }) { + return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); + } + elsif ($group =~ m/owner$/i and $groupType ne 'owner') { + return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); + } + elsif ($group =~ m/-tty$/i and $groupType ne 'tty') { + return R('KO_FORBIDDEN_SUFFIX', msg => 'Forbidden suffix in group name'); + } + elsif ($group =~ m/^key/i and not grep { $groupType eq $_ } qw{ key gatekeeper owner }) { + return R('KO_FORBIDDEN_PREFIX', msg => 'Forbidden prefix in group name'); + } + elsif ($group =~ /^(key)?(private|root|user|self|legacy|osh)(-(gatekeeper|aclkeeper|owner))?$/) { + return R('KO_FORBIDDEN_NAME', msg => 'Forbidden group name'); + } + elsif ($group =~ m/^([a-zA-Z0-9_-]+)$/) { + $group = $1; # untainted + if ($groupType eq 'key' and $group !~ m/^key/) { + return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); + } + elsif ($groupType eq 'gatekeeper' and $group !~ m/^key.+-gatekeeper$/) { + return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); + } + elsif ($groupType eq 'owner' and $group !~ m/^key.+-owner$/) { + return R('KO_MISSING_PREFIX', msg => "The group $group should have a prefix (group type $groupType)"); + } + elsif ($groupType and $groupType eq 'tty' and $group !~ m/-tty$/) { + return R('KO_MISSING_SUFFIX', msg => "The group $group should have a suffix (group type $groupType)"); + } + my $shortGroup = $group; + $shortGroup =~ s/^key|^osh-|-(gatekeeper|aclkeeper|owner|tty)$//g; + + if (length($group) > 32) { + + # 32 max for the whole group (system limit) + return R('KO_NAME_TOO_LONG', msg => 'Group name is too long (system limit)'); + } + + if ($groupType ne 'osh' and length($shortGroup) > 18) { + + # 18 max for the short group (except for osh groups) + return R('KO_NAME_TOO_LONG', msg => 'Group name is too long (code limit)'); + } + + return R('OK', value => {group => $group, shortGroup => $shortGroup}); + } + return R('KO_FORBIDDEN_NAME', msg => 'Group name contains invalid characters'); +} + +sub is_valid_group_and_existing { + my %params = @_; + + my $fnret = OVH::Bastion::is_valid_group(%params); + $fnret or return $fnret; + $params{'group'} = $fnret->value->{'group'}; + return OVH::Bastion::is_group_existing(%params); +} + +# Add a user to a group +sub add_user_to_group { + my %params = @_; + my $group = $params{'group'}; + my $user = $params{'user'}; + my $accountType = $params{'accountType'}; + my $groupType = $params{'groupType'}; + my $fnret; + + osh_debug('validating user'); + $fnret = OVH::Bastion::is_account_valid(account => $user, accountType => $accountType); + $fnret or return $fnret; + osh_debug('user is ok'); + $user = $fnret->value->{'account'} || $fnret->value->{'realm'}; + + osh_debug('validating group name'); + if ($groupType) { + $fnret = OVH::Bastion::is_valid_group(group => $group, groupType => $groupType); + } + else { + $fnret = OVH::Bastion::is_valid_group(group => $group); + } + $fnret or return $fnret; + osh_debug('group name is ok'); + $group = $fnret->value->{'group'}; + + $fnret = OVH::Bastion::sys_addmembertogroup(group => $group, user => $user); + $fnret or return R('ERR_USERMOD_FAILED', msg => "Error while adding $user to group $group (" . $fnret->msg . ")"); + return R('OK'); +} + +my %_cache_get_group_list; + +sub get_group_list { + my %params = @_; + my $groupType = $params{'groupType'}; + my $cache = $params{'cache'}; # if true, allow cache use + + if ($cache and $_cache_get_group_list{$groupType}) { + return $_cache_get_group_list{$groupType}; + } + + my $antiloop = 9000; + my %groups; + setgrent(); + while (my @nextgroup = getgrent()) { + $antiloop-- < 0 and last; + my ($name, $passwd, $gid, $members) = @nextgroup; + if ( $groupType eq 'key' + and $name =~ /^key/ + and $name !~ /-(owner|gatekeeper|aclkeeper)$/ + and not grep { $name eq $_ } qw{ keykeeper keyreader }) + { + $name =~ s/^key//; + my @members = split(/ /, $members); + $groups{$name} = {gid => $gid, members => split(/ /, $members)}; + } + } + $_cache_get_group_list{$groupType} = R('OK', value => \%groups); + return $_cache_get_group_list{$groupType}; +} + +my $_cache_get_account_list = undef; + +sub get_account_list { + my %params = @_; + my $accounts = $params{'accounts'} || []; + my $cache = $params{'cache'}; # if true, allow cache use + + if ($cache and $_cache_get_account_list) { + return $_cache_get_account_list; + } + + my %users; + + if (@$accounts) { + foreach (@$accounts) { + my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell, $expire) = getpwnam($_); + next unless OVH::Bastion::is_bastion_account_valid_and_existing(account => $name); + $users{$name} = {name => $name, uid => $uid, gid => $gid, home => $dir, shell => $shell}; + } + } + else { + setpwent(); + while (my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell, $expire) = getpwent()) { + next unless OVH::Bastion::is_bastion_account_valid_and_existing(account => $name); + $users{$name} = {name => $name, uid => $uid, gid => $gid, home => $dir, shell => $shell}; + } + } + $_cache_get_account_list = R('OK', value => \%users); + return $_cache_get_account_list; +} + +sub get_realm_list { + my %params = @_; + my $realms = $params{'realms'} || []; + + my %users; + + setpwent(); + + if (@$realms) { + foreach (@$realms) { + my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell, $expire) = getpwnam("realm_" . $_); + next unless OVH::Bastion::is_bastion_account_valid_and_existing(account => $name, accountType => "realm"); + $name =~ s{^realm_}{}; + $users{$name} = {name => $name}; + } + } + else { + setpwent(); + while (my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir, $shell, $expire) = getpwent()) { + next unless OVH::Bastion::is_bastion_account_valid_and_existing(account => $name, accountType => "realm"); + $name =~ s{^realm_}{}; + $users{$name} = {name => $name}; + } + } + + return R('OK', value => \%users); +} + +# check if account is a bastion admin (gives access to adminXyz commands) +# hint: an admin is also always a superowner +sub is_admin { + my %params = @_; + my $sudo = $params{'sudo'}; # we're run under sudo + my $account = $params{'account'}; + + if (not $account) { + $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; + } + + if (not $account) { + return R('ERR_INTERNAL_ERROR'); + } + if (not $sudo and exists $ENV{'SUDO_USER'}) { + + # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this + if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'unexpected-sudo'], ['account', $params{'account'}], ['plugin', 'is_admin'], ['params', join(" ", @_)],] + ); + return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); + } + } + + my $adminList = OVH::Bastion::config('adminAccounts')->value(); + if (grep { $account eq $_ } @$adminList) { + return OVH::Bastion::is_user_in_group(group => "osh-admin", user => $account); + } + return R('KO_ACCESS_DENIED'); +} + +# check if account is a superowner +# hint: an admin is also always a superowner +sub is_super_owner { + my %params = @_; + my $sudo = $params{'sudo'}; # we're run under sudo + my $account = $params{'account'}; + + if (not $account) { + $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; + } + + if (not $account) { + return R('ERR_INTERNAL_ERROR'); + } + if (not $sudo and exists $ENV{'SUDO_USER'}) { + + # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this + if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'unexpected-sudo'], ['account', $params{'account'}], ['plugin', 'is_super_owner'], ['params', join(" ", @_)],] + ); + return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); + } + } + + my $superownerList = OVH::Bastion::config('superOwnerAccounts')->value(); + if (grep { $account eq $_ } @$superownerList) { + return OVH::Bastion::is_user_in_group(group => "osh-superowner", user => $account); + } + + # if admin, then we're good too + return OVH::Bastion::is_admin(account => $account, sudo => $sudo); +} + +# check if account is an auditor +sub is_auditor { + my %params = @_; + my $sudo = $params{'sudo'}; # we're run under sudo + my $account = $params{'account'}; + + if (not $account) { + $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; + } + + if (not $account) { + return R('ERR_INTERNAL_ERROR'); + } + if (not $sudo and exists $ENV{'SUDO_USER'}) { + + # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this + if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'unexpected-sudo'], ['account', $params{'account'}], ['plugin', 'is_auditor'], ['params', join(" ", @_)],] + ); + return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); + } + } + + return OVH::Bastion::is_user_in_group(group => "osh-auditor", user => $account); +} + +# used by funcs below +sub _has_group_role { + my %params = @_; + my $account = $params{'account'}; + my $shortGroup = $params{'group'}; + my $role = $params{'role'}; # regular or gatekeeper or owner + my $superowner = $params{'superowner'}; # allow superowner (will always return yes if so) + my $sudo = $params{'sudo'}; # are we run under sudo ? + + if (not $account) { + $account = $sudo ? $ENV{'SUDO_USER'} : OVH::Bastion::get_user_from_env()->value; + } + if (not $account) { + return R('ERR_MISSING_PARAMETER', msg => 'Expected parameter account'); + } + if (not $sudo and exists $ENV{'SUDO_USER'}) { + + # only legit case is if we have osh.pl under sudo because of an admin (adminSudo / ssh-as), check this + if (not OVH::Bastion::is_admin(account => $ENV{'SUDO_USER'}, sudo => 1)) { + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'security', + fields => [['type', 'unexpected-sudo'], ['account', $params{'account'}], ['plugin', '_has_group_role'], ['params', join(" ", @_)],] + ); + return R('ERR_SECURITY_VIOLATION', msg => "Wasn't expected to be called under sudo, but was, with user " . $ENV{'SUDO_USER'}); + } + } + + my $group = "key$shortGroup"; + + # "regular" means "member or guest", i.e. user is in group key$GROUPNAME + if ($role ne 'regular') { + $group .= "-$role"; + } + + # for the realm case, we need to test sysaccount and not just account + my $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + my $sysaccount = $fnret->value->{'sysaccount'}; + + my $fnret = OVH::Bastion::is_user_in_group(user => $sysaccount, group => $group); + osh_debug("is <$sysaccount> in <$group> ? => " . ($fnret ? 'yes' : 'no')); + if ($fnret) { + $fnret->{'value'} = {account => $account, sysaccount => $sysaccount}; + return $fnret; + } + + # if superowner allowed, try it + if ($superowner) { + if (OVH::Bastion::is_super_owner(account => $sysaccount, sudo => $sudo)) { + osh_debug("is <$sysaccount> in <$group> ? => no but superowner so YES!"); + return R('OK', value => {account => $account, sysaccount => $sysaccount, superowner => 1}); + } + } + + # not admin or no superowner allowed... return is_user_in_group status but fixup the value if true + $fnret->{'value'} = {account => $account, sysaccount => $sysaccount} if $fnret; + return $fnret; +} + +sub is_group_aclkeeper { + my %params = @_; + $params{'role'} = 'aclkeeper'; + return _has_group_role(%params); +} + +sub is_group_gatekeeper { + my %params = @_; + $params{'role'} = 'gatekeeper'; + return _has_group_role(%params); +} + +sub is_group_owner { + my %params = @_; + $params{'role'} = 'owner'; + return _has_group_role(%params); +} + +sub _is_group_member_or_guest { + my %params = @_; + my $shortGroup = $params{'group'}; + my $want = $params{'want'}; # guest or member + + my $fnret = _has_group_role(%params, role => "regular"); + $fnret or return $fnret; + + my $account = $fnret->value()->{'account'}; + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $remoteaccount = $fnret->value->{'remoteaccount'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + + my $group = "key$shortGroup"; + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); + $fnret or return $fnret; + $group = $fnret->value()->{'group'}; + $shortGroup = $fnret->value()->{'shortGroup'}; # untainted + + my $weare = 'guest'; + + # to be a member (old name: "full member"); one also need to have the symlink + my $prefix = $remoteaccount ? "allowed_$remoteaccount" : "allowed"; + if (-l "/home/allowkeeper/$sysaccount/$prefix.ip.$shortGroup") { + + # -l => test that file exists and is a symlink + # -r => test that the symlink dest still exists => REMOVED, because we (the caller) might not have the right to read the file if we're not member or guest ourselves + $weare = 'member'; + } + + return R('OK') if ($weare eq $want); + return R('KO'); +} + +# test if account is strictly a guest (i.e. if a member, then answer is no) +sub is_group_guest { + my %params = @_; + $params{'want'} = 'guest'; + return _is_group_member_or_guest(%params); +} + +# test if account is strictly a member (i.e. if a guest, then answer is no) +sub is_group_member { + my %params = @_; + $params{'want'} = 'member'; + return _is_group_member_or_guest(%params); +} + +sub get_remote_accounts_from_realm { + my %params = @_; + my $realm = $params{'realm'}; + + $realm = "realm_$realm" if $realm !~ /^realm_/; + my $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $realm, accountType => "realm"); + $fnret or return $fnret; + + my $sysaccount = $fnret->value->{'sysaccount'}; + my $allowkeeperdir = "/home/allowkeeper/$sysaccount/"; + + my %accounts; + if (opendir(my $dh, "/home/allowkeeper/$sysaccount")) { + while (my $filename = readdir($dh)) { + next if $filename !~ /allowed_([a-zA-Z0-9_-]+)\./; + $accounts{$1} = 1; + } + closedir($dh); + } + return R('OK', value => [sort keys %accounts]); +} + +sub is_valid_ttl { + my %params = @_; + my $ttl = $params{'ttl'}; + my $seconds; + + if ($ttl =~ /^\d+$/) { + return R('OK', value => {seconds => $ttl + 0}); + } + elsif ($ttl =~ m{^(\d+[smhdwy]*)+$}i) { + while ($ttl =~ m{(\d+)([smhdwy])?}gi) { + if ($2 eq 'y') { $seconds += $1 * 86400 * 365 } + elsif ($2 eq 'w') { $seconds += $1 * 86400 * 7 } + elsif ($2 eq 'd') { $seconds += $1 * 86400 } + elsif ($2 eq 'h') { $seconds += $1 * 3600 } + elsif ($2 eq 'm') { $seconds += $1 * 60 } + else { $seconds += $1 } + } + return R('OK', value => {seconds => $seconds + 0}); + } + + return R('KO_INVALID_PARAMETER', msg => "Invalid TTL ($ttl), expected an amount of seconds, or a duration string such as '2d8h15m'"); +} + +1; diff --git a/lib/perl/OVH/Bastion/configuration.inc b/lib/perl/OVH/Bastion/configuration.inc new file mode 100644 index 0000000..48bc6b9 --- /dev/null +++ b/lib/perl/OVH/Bastion/configuration.inc @@ -0,0 +1,536 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +use JSON; +use Fcntl qw{ :mode :DEFAULT }; + +sub load_configuration_file { + my %params = @_; + my $file = $params{'file'}; + + # if $secure is set, won't load the file if it's not writable by root only + # it won't allow symlinks either + my $secure = $params{'secure'}; + + if ($secure) { + my @stat = lstat($file); + if (@stat) { + if ($stat[4] != 0 or $stat[5] != 0) { + return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is not owned by root, report to your sysadmin."); + } + if (!S_ISREG($stat[2])) { + return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is not a regular file, report to your sysadmin."); + } + if (S_IMODE($stat[2]) & S_IWOTH) { + return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is world-writable, report to your sysadmin."); + } + } + + # no @stat ? file doesn't exist, we'll error just below + } + + return OVH::Bastion::json_load(file => $file); +} + +sub main_configuration_directory { + if (!-d "/etc/bastion" && -d "/usr/local/etc/bastion") { + + # if this dir exists and /etc/bastion doesn't, use /usr/local + return "/usr/local/etc/bastion"; + } + elsif (!-d "/etc/bastion" && -d "/usr/pkg/etc/bastion") { + + # if this dir exists and /etc/bastion doesn't, use /usr/local + return "/usr/pkg/etc/bastion"; + } + + # use /etc in all other cases + return "/etc/bastion"; +} + +my $_cache_config = undef; + +sub load_configuration { + my %params = @_; + my $mock_data = $params{'mock_data'}; + + if (defined $mock_data && !OVH::Bastion::is_mocking()) { + + # if we're overriding configuration with mock_data without being in mocking mode, we have a problem + die("Attempted to load_configuration() with mock_data without being in mocking mode"); + } + + if (ref $_cache_config eq 'HASH') { + return R('OK', value => $_cache_config); + } + + my $C; + if (!$mock_data) { + my $file = OVH::Bastion::main_configuration_directory() . "/bastion.conf"; + + # check that file exists and is readable + if (not -r $file) { + return R('ERR_CANNOT_LOAD_CONFIGURATION', msg => "Configuration file $file does not exist or is not readable"); + } + + $C = OVH::Bastion::load_configuration_file(file => $file, secure => 1); + $C or return $C; + $C = $C->value; + } + else { + $C = $mock_data; + } + + # define deprecated <=> new key names association + my %new2old = qw( + accountCreateDefaultPersonalAccesses accountCreateDefaultPrivateAccesses + adminAccounts adminLogins + allowedIngressSshAlgorithms allowedSshAlgorithms + allowedEgressSshAlgorithms allowedSshAlgorithms + bastionCommand cacheCommand + bastionName cacheName + ingressKeysFrom ipWhiteList + ingressKeysFromAllowOverride ipWhiteListAllowOverride + minimumIngressRsaKeySize minimumRsaKeySize + minimumEgressRsaKeySize minimumRsaKeySize + egressKeysFrom personalKeyFrom + ); + + # if we're missing some new key names, look for old keys and take their value + while (my ($new, $old) = each %new2old) { + $C->{$new} //= $C->{$old}; + } + + # now validate, lint and normalize the conf + + $C->{'bastionName'} ||= 'fix-my-config-please-missing-bastion-name'; + + $C->{'bastionCommand'} ||= "ssh ACCOUNT\@HOSTNAME -t -- "; + + $C->{'defaultLogin'} = "" if (not defined $C->{'defaultLogin'}); + $C->{'defaultLogin'} =~ s/[^a-zA-Z0-9_-]//g; + + $C->{'accountUidMin'} = 2000 if (not defined $C->{'accountUidMin'} or $C->{'accountUidMin'} !~ /^\d+$/); + $C->{'accountUidMin'} > 100 or $C->{'accountUidMin'} = 100; + $C->{'accountUidMin'} < 999999999 or $C->{'accountUidMin'} = 999999999; # usually 2^31-2 but well... + + $C->{'accountUidMax'} = 99999 if (not defined $C->{'accountUidMax'} or $C->{'accountUidMax'} !~ /^\d+$/); + $C->{'accountUidMax'} > 100 or $C->{'accountUidMax'} = 100; + $C->{'accountUidMax'} < 999999999 or $C->{'accountUidMax'} = 999999999; # usually 2^31-2 but well... + + $C->{'accountUidMax'} = $C->{'accountUidMin'} + 1000 if ($C->{'accountUidMin'} + 1000 > $C->{'accountUidMax'}); + + $C->{'ttyrecGroupIdOffset'} = 100000 if (not defined $C->{'ttyrecGroupIdOffset'} or $C->{'ttyrecGroupIdOffset'} !~ /^\d+$/); + $C->{'ttyrecGroupIdOffset'} < 999999999 or $C->{'ttyrecGroupIdOffset'} = 999999999; + if ($C->{'ttyrecGroupIdOffset'} < $C->{'accountUidMax'} - $C->{'accountUidMin'}) { + + # avoid overlap + $C->{'ttyrecGroupIdOffset'} = ($C->{'accountUidMax'} - $C->{'accountUidMin'}) + 1; + } + + foreach my $key (qw{ allowedIngressSshAlgorithms allowedIngressSshAlgorithms }) { + $C->{$key} = ['rsa', 'ecdsa', 'ed25519'] if (not defined $C->{$key} or ref $C->{$key} ne 'ARRAY'); + } + + foreach my $key (qw{ Ingress Egress }) { + my $minkey = "minimum${key}RsaKeySize"; + my $maxkey = "maximum${key}RsaKeySize"; + $C->{$minkey} = 2048 if (not defined $C->{$minkey} or $C->{$minkey} !~ /^\d+$/); + $C->{$minkey} >= 1024 or $C->{$minkey} = 1024; + $C->{$minkey} <= 16384 or $C->{$minkey} = 16384; + $C->{$maxkey} = 8192 if (not defined $C->{$maxkey} or $C->{$maxkey} !~ /^\d+$/); + $C->{$maxkey} >= 1024 or $C->{$maxkey} = 1024; + $C->{$maxkey} <= 32768 or $C->{$maxkey} = 32768; + $C->{$minkey} = $C->{$maxkey} if ($C->{$minkey} > $C->{$maxkey}); # ensure min <= max + } + + $C->{'defaultAccountEgressKeyAlgorithm'} ||= 'rsa'; + if (!grep { $C->{'defaultAccountEgressKeyAlgorithm'} eq $_ } qw{ rsa ecdsa ed25519 }) { + $C->{'defaultAccountEgressKeyAlgorithm'} = 'rsa'; + } + + $C->{'defaultAccountEgressKeySize'} = 0 if $C->{'defaultAccountEgressKeySize'} !~ /^\d+$/; + if ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'rsa') { + $C->{'defaultAccountEgressKeySize'} ||= 4096; + $C->{'defaultAccountEgressKeySize'} = 1024 if $C->{'defaultAccountEgressKeySize'} < 1024; + $C->{'defaultAccountEgressKeySize'} = 32768 if $C->{'defaultAccountEgressKeySize'} > 32768; + } + elsif ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'ecdsa') { + $C->{'defaultAccountEgressKeySize'} ||= 521; + if (!grep { $C->{'defaultAccountEgressKeySize'} eq $_ } qw{ 256 384 521 }) { + $C->{'defaultAccountEgressKeySize'} = 521; + } + } + elsif ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'ed25519') { + $C->{'defaultAccountEgressKeySize'} = 256; + } + + $C->{'sshClientDebugLevel'} = 0 if (not defined $C->{'sshClientDebugLevel'} or $C->{'sshClientDebugLevel'} !~ /^\d+$/); + $C->{'sshClientDebugLevel'} > 3 and $C->{'sshClientDebugLevel'} = 3; + + $C->{'accountMaxInactiveDays'} = 0 + if (not defined $C->{'accountMaxInactiveDays'} or $C->{'accountMaxInactiveDays'} !~ /^\d+$/); + + $C->{'interactiveModeTimeout'} = 15 + if (not defined $C->{'interactiveModeTimeout'} or $C->{'interactiveModeTimeout'} !~ /^\d+$/); + $C->{'syslogFacility'} = 'local7' if (not defined $C->{'syslogFacility'} or $C->{'syslogFacility'} !~ /^\S+$/); + $C->{'syslogDescription'} = 'bastion' if (not defined $C->{'syslogDescription'} or $C->{'syslogDescription'} !~ /^\S+$/); + + $C->{'moshTimeoutNetwork'} = 86400 if (not defined $C->{'moshTimeoutNetwork'} or $C->{'moshTimeoutNetwork'} !~ /^\d+$/); + $C->{'moshTimeoutSignal'} = 30 if (not defined $C->{'moshTimeoutSignal'} or $C->{'moshTimeoutSignal'} !~ /^\d+$/); + $C->{'moshCommandLine'} = "" if (not defined $C->{'moshCommandLine'}); + + $C->{'ttyrecFilenameFormat'} = '%Y-%m-%d.%H-%M-%S.#usec#.&uniqid.ttyrec' if (not $C->{'ttyrecFilenameFormat'}); + + $C->{'idleLockTimeout'} = 0 if (not defined $C->{'idleLockTimeout'} or $C->{'idleLockTimeout'} !~ /^\d+$/); + $C->{'idleKillTimeout'} = 0 if (not defined $C->{'idleKillTimeout'} or $C->{'idleKillTimeout'} !~ /^\d+$/); + $C->{'warnBeforeLockSeconds'} = 0 if (not defined $C->{'warnBeforeLockSeconds'} or $C->{'warnBeforeLockSeconds'} !~ /^\d+$/); + $C->{'warnBeforeKillSeconds'} = 0 if (not defined $C->{'warnBeforeKillSeconds'} or $C->{'warnBeforeKillSeconds'} !~ /^\d+$/); + + if (!grep { $C->{'accountMFAPolicy'} eq $_ } qw{ disabled enabled password-required totp-required any-required }) { + $C->{'accountMFAPolicy'} = 'enabled'; + } + $C->{'MFAPasswordInactiveDays'} = -1 if (!defined $C->{'MFAPasswordInactiveDays'} || $C->{'MFAPasswordInactiveDays'} !~ /^-\d+$/); + $C->{'MFAPasswordMinDays'} = 0 if (!defined $C->{'MFAPasswordMinDays'} || $C->{'MFAPasswordMinDays'} !~ /^-?\d+$/); + $C->{'MFAPasswordMaxDays'} = 90 if (!defined $C->{'MFAPasswordMaxDays'} || $C->{'MFAPasswordMaxDays'} !~ /^-?\d+$/); + $C->{'MFAPasswordWarnDays'} = 15 if (!defined $C->{'MFAPasswordWarnDays'} || $C->{'MFAPasswordWarnDays'} !~ /^-?\d+$/); + + # if kill timeout is lower than lock timeout, just unset lock timeout + $C->{'idleLockTimeout'} = 0 if ($C->{'idleKillTimeout'} <= $C->{'idleLockTimeout'}); + + # booleans that can only be 0 or 1 and default to 1 + foreach my $key (qw{ enableSyslog enableGlobalAccessLog enableAccountAccessLog enableGlobalSqlLog enableAccountSqlLog displayLastLogin }) { + $C->{$key} = 1 if (not defined $C->{$key} or $C->{$key} !~ /^\d+$/); + $C->{$key} > 1 and $C->{$key} = 1; + } + + # booleans that can only be 0 or 1 and default to 0 + foreach my $key ( + qw{ interactiveModeAllowed readOnlySlaveMode sshClientHasOptionE ingressKeysFromAllowOverride + moshAllowed debug keyboardInteractiveAllowed passwordAllowed telnetAllowed remoteCommandEscapeByDefault + accountExternalValidationDenyOnFailure } + ) + { + $C->{$key} = 0 if (not defined $C->{$key} or $C->{$key} !~ /^\d+$/); + $C->{$key} > 1 and $C->{$key} = 1; + } + + # arrays that default to empty + foreach my $key ( + qw{ accountCreateSupplementaryGroups accountCreateDefaultPrivateAccesses alwaysActiveAccounts + superOwnerAccounts ingressKeysFrom egressKeysFrom adminAccounts allowedNetworks forbiddenNetworks + ttyrecAdditionalParameters MFAPostCommand } + ) + { + $C->{$key} = [] if ref $C->{$key} ne 'ARRAY'; + } + + # lint the contents of some arrays + foreach my $key (qw{ ingressKeysFrom egressKeysFrom }) { + s=[^0-9.:/]==g for @{$C->{$key}}; + } + $C->{'adminAccounts'} = [ + grep { OVH::Bastion::is_bastion_account_valid_and_existing(account => $_) } + map { s/[^a-zA-Z0-9_-]//g; $_ } @{$C->{'adminAccounts'}} + ]; + + $C->{'documentationURL'} ||= "https://ovh.github.io/bastion/"; + + # we've checked everything. now forcibly untaint all of it. + foreach my $key (keys %$C) { + ref $C->{$key} eq '' or next; + ($C->{$key}) = $C->{$key} =~ m{(.+)}; + $C->{$key} += 0 if $C->{$key} =~ /^\d+$/; + } + + $_cache_config = $C; + return R('OK', value => $C); +} + +sub config { + my $key = shift; + + my $fnret = OVH::Bastion::load_configuration(); + $fnret or return $fnret; + if (exists $fnret->value->{$key}) { + return R('OK', value => $fnret->value->{$key}); + } + return R('ERR_UNKNOWN_CONFIG_PARAMETER'); +} + +sub account_config { + my %params = @_; + my $account = $params{'account'} || OVH::Bastion::get_user_from_env()->value; + my $key = $params{'key'}; + my $value = $params{'value'}; # only for setter + my $delete = $params{'delete'}; # if true, delete the config param entirely + my $public = $params{'public'}; # if true, check in /home/allowkeeper/$account instead of /home/$account + my $fnret; + + if (my @missingParameters = grep { not defined $params{$_} } qw{ account key }) { + local $" = ', '; + return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on account_config() call"); + } + + if ($key !~ /^[a-zA-Z0-9_-]+$/) { + return R('ERR_INVALID_PARAMETER', msg => "Invalid configuration key asked ($key)"); + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => ($account =~ /^realm_/ ? 'realm' : 'normal')); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + my $remoteaccount = $fnret->value->{'remoteaccount'}; + + my $rootdir; + if ($public) { + $rootdir = "/home/allowkeeper/$sysaccount"; + } + else { + $rootdir = (getpwnam($sysaccount))[7]; + } + + if (!-d $rootdir) { + return R('ERR_DIRECTORY_NOT_FOUND', msg => "Home directory of $account ($rootdir) doesn't exist"); + } + my $prefix = $remoteaccount ? "config_$remoteaccount" : "config"; + my $filename = "$rootdir/$prefix.$key"; + + if ($delete) { + return R('OK') if (unlink($filename)); + return R('ERR_UNLINK_FAILED', msg => "Couldn't delete account $account config $key with public=$public ($!)"); + } + elsif (defined $value) { + + # setter mode + unlink($filename); # remove any previous value + my $fh; + if (!sysopen($fh, $filename, O_RDWR | O_CREAT | O_EXCL)) # sysopen: avoid symlink attacks + { + return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for write ($!)"); + } + print $fh $value; + close($fh); + chmod 0644, $filename; + if ($public) { + + # need to chown to allowkeeper:allowkeeper + my (undef, undef, $allowkeeperuid, $allowkeepergid) = getpwnam("allowkeeper"); + chown $allowkeeperuid, $allowkeepergid, $filename; + } + return R('OK'); + } + else { + # getter mode + my $fh; + if (!open($fh, '<', $filename)) { + return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for read ($!)"); + } + my $value = do { local $/; <$fh> }; + close($fh); + return R('OK', value => $value); + } + + return R('ERR_INTERNAL'); # we shouldn't be here +} + +my %_plugin_config_cache; + +sub plugin_config { + my %params = @_; + my $plugin = $params{'plugin'}; + my $key = $params{'key'}; + my $fnret; + + if (my @missingParameters = grep { not defined $params{$_} } qw{ plugin }) { + local $" = ', '; + return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on plugin_config() call"); + } + + if (not exists $_plugin_config_cache{$plugin}) { + + # sanitize $plugin + if ($plugin !~ /^[a-zA-Z0-9_-]{1,128}$/) { + return R('ERR_INVALID_PARAMETER', msg => "Invalid parameter for plugin"); + } + + # if not in cache, load it + my %config; + + # 1of2) load from builtin config (plugin.json) + my $pluginPath = $OVH::Bastion::BASEPATH . '/bin/plugin'; + undef $fnret; + foreach my $pluginDir (qw{ open restricted group-gatekeeper group-aclkeeper group-owner admin }) { + if (-e "$pluginPath/$pluginDir/$plugin") { + $fnret = OVH::Bastion::load_configuration_file(file => "$pluginPath/$pluginDir/$plugin.json"); + if ($fnret->err eq 'KO_CANNOT_OPEN_FILE') { + + # chmod error, don't fail silently + warn_syslog("Can't read configuration file '$pluginPath/$pluginDir/$plugin.json'"); + return R('ERR_CONFIGURATION_ERROR', msg => "Configuration file has improper rights, ask your sysadmin!"); + } + last; + } + } + if ($fnret && ref $fnret->value eq 'HASH') { + %config = %{$fnret->value}; + } + + # 2of2) load from /etc config (will NOT override plugin.json keys) + $fnret = OVH::Bastion::load_configuration_file(file => "/etc/bastion/plugin.$plugin.conf", secure => 1); + if ($fnret->err eq 'KO_CANNOT_OPEN_FILE') { + + # chmod error, don't fail silently + warn_syslog("Can't read configuration file '/etc/bastion/plugin.$plugin.conf'"); + return R('ERR_CONFIGURATION_ERROR', msg => "Configuration file has improper rights, ask your sysadmin!"); + } + if ($fnret && ref $fnret->value eq 'HASH') { + + # avoid overriding keys + foreach my $key (keys %{$fnret->value}) { + $config{$key} = $fnret->value->{$key} if not exists $config{$key}; + } + } + + $_plugin_config_cache{$plugin} = \%config; + } + + # if no $key is specified, return all config + return R('OK', value => $_plugin_config_cache{$plugin}) if not defined $key; + + # or just the requested key's value otherwise (might be undef!) + return R('OK', value => $_plugin_config_cache{$plugin}{$key}); +} + +sub group_config { + my %params = @_; + my $group = $params{'group'}; + my $key = $params{'key'}; + my $value = $params{'value'}; # only for setter + my $secret = $params{'secret'}; # only for setter, if true, only group members can read this config key + my $delete = $params{'delete'}; # only for setter, if true, delete the config param entirely + my $fnret; + + if (my @missingParameters = grep { not defined $params{$_} } qw{ group key }) { + local $" = ', '; + return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on group_config() call"); + } + + if ($key !~ /^[a-zA-Z0-9_-]+$/) { + return R('ERR_INVALID_PARAMETER', msg => "Invalid configuration key asked ($key)"); + } + + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret or return $fnret; + + $group = $fnret->value->{'group'}; + my $shortGroup = $fnret->value->{'shortGroup'}; + + my $filename = "/home/$group/config.$key"; + + if ($delete) { + return R('OK') if (unlink($filename)); + return R('ERR_UNLINK_FAILED', msg => "Couldn't delete group $shortGroup config $key ($!)"); + } + elsif (defined $value) { + + # setter mode + unlink($filename); # remove any previous value + my $fh; + if (!sysopen($fh, $filename, O_RDWR | O_CREAT | O_EXCL)) # sysopen: avoid symlink attacks + { + return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for write ($!)"); + } + print $fh $value; + close($fh); + chmod($secret ? 0640 : 0644), $filename; + + # need to chown to group:group + my (undef, undef, $groupuid, $groupgid) = getpwnam($group); + chown $groupuid, $groupgid, $filename; + return R('OK'); + } + else { + # getter mode + my $fh; + if (!open($fh, '<', $filename)) { + return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for read ($!)"); + } + { + local $/ = undef; + $value = <$fh>; + } + close($fh); + return R('OK', value => $value); + } + + return R('ERR_INTERNAL'); # we shouldn't be here +} + +sub json_load { + my %params = @_; + + # Check params + my $file = $params{'file'}; + my $keywords = $params{'keywords'} || []; + + if (!$file) { + return R('KO_MISSING_PARAMETER', msg => "Missing 'file' parameter"); + } + + # Load file content + my $rawConf; + if (open(my $fh, '<', $file)) { + foreach (<$fh>) { + chomp; + s/^((?:(?:[^"]*"){2}|[^"]*)*[^"]*)\/\/.*$/$1/; # Remove comment that start with // + /^\s*#/ and next; # Comment start with ^# + $rawConf .= $_ . "\n"; + } + close $fh; + } + else { + # either the file doesn't exist, or we don't have the right to read it. + if (-e $file) { + return R('KO_CANNOT_OPEN_FILE', msg => "Couldn't open specified file ($!)"); + } + else { + return R('KO_NO_SUCH_FILE', msg => "File '$file' doesn't exist"); + } + } + + # Clean file content + + # Remove bloc comment + $rawConf =~ s/\/\*\*.+?\*\///sgm; + + # Add {} if needed + if ($rawConf !~ /^\{.*\}[\n]?$/sm) { + $rawConf = "{\n" . $rawConf . "}\n"; + } + + # + # Parse file content + # + my $configuration; + eval { $configuration = decode_json($rawConf); }; + if ($@) { + return R('KO_INVALID_JSON', msg => "Error while trying to decode JSON configuration from file: $@"); + } + + # Check that each given keywords are defined + my @missing = map { defined($configuration->{$_}) ? () : $_ } keys %$configuration; + if (@missing) { + return R( + 'KO_MISSING_CONFIGURATION', + value => $configuration, + msg => "Configuration is lacking mandatory keywords: " . join(', ', @missing) + ); + } + return R('OK', value => $configuration); +} + +1; diff --git a/lib/perl/OVH/Bastion/execute.inc b/lib/perl/OVH/Bastion/execute.inc new file mode 100644 index 0000000..cab0a60 --- /dev/null +++ b/lib/perl/OVH/Bastion/execute.inc @@ -0,0 +1,393 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +use IO::Handle; +use IPC::Open3; +use Symbol 'gensym'; +use IO::Select; +use POSIX ":sys_wait_h"; +use JSON; +use Config; + +# Get signal names, i.e. signal 9 is SIGKILL, etc. +my %signum2string; +@signum2string{split ' ', $Config{sig_num}} = map { "SIG$_" } split ' ', $Config{sig_name}; + +sub _sysret2human { + my $sysret = shift; + if ($sysret == -1) { + return R('OK', msg => "error: failed to execute ($!)"); + } + elsif ($sysret & 127) { + my $signal = $sysret & 127; + my $coredump = $sysret & 128; + return R( + 'OK', + value => { + coredump => $coredump ? \1 : \0, + signal => $signum2string{$signal} || $signal, + status => 0, + }, + msg => sprintf("signal %d (%s)%s", $signal, $signum2string{$signal}, $coredump ? ' and coredump' : '') + ); + } + else { + return R('OK', value => {coredump => \0, signal => 0, status => $sysret >> 8}, msg => sprintf("status %d", $sysret >> 8)); + } +} + +sub execute { + my %params = @_; + my $cmd = $params{'cmd'}; # command to execute, must be an array ref (with possible parameters) + my $expects_stdin = $params{'expects_stdin'}; # the command called expects stdin, pipe caller stdin to it + my $noisy_stdout = $params{'noisy_stdout'}; # capture stdout but print it too + my $noisy_stderr = $params{'noisy_stderr'}; # capture stderr but print it too + my $is_helper = $params{'is_helper'}; # hide JSON returns from stdout even if noisy_stdout + my $is_binary = $params{'is_binary'}; # used for e.g. scp, don't bother mimicking readline(), we lose debug and stdout/stderr are NOT returned to caller + my $stdin_str = $params{'stdin_str'}; # string to push to the STDIN of the command + my $must_succeed = $params{'must_succeed'}; # if the executed command returns a non-zero exit value, turn OK_NON_ZERO_EXIT to ERR_NON_ZERO_EXIT + my $max_stdout_bytes = $params{'max_stdout_bytes'}; # if the amount of stored stdout bytes exceeds this, halt the command and return to caller + my $system = $params{'system'}; # if set to 1, will use system() instead of open3(), needed for some plugins + + $noisy_stderr = $noisy_stdout = 1 if ($ENV{'PLUGIN_DEBUG'} or $is_binary); + + my $readsize = $is_binary ? 16384 : 1; # XXX needs to be enhanced to be > 1 even for non-binary + my $fnret; + +=cut only to debug slow calls + if (not $is_binary) + { + require Carp; + open(SLOW, '>>', '/dev/shm/slowexecute'); + print SLOW Carp::longmess(join('^',@$cmd))."\n\n"; + close(SLOW); + } +=cut + + #=cut only to debug tainted stuff + require Scalar::Util; + foreach (@$cmd) { + if (Scalar::Util::tainted($_)) { + + # to be able to warn under -T; untaint it. we're going to crash right after anyway. + my ($untainted) = $_ =~ /(.+)/; + require Carp; + warn(Carp::longmess("would exec <" . join('^', @$cmd) . "> but param '$untainted' is tainted!")); + osh_warn("about to execute a cmd but param '$untainted' is tainted, I'm gonna crash!"); + } + } + + #=cut + + if ($system) { + my $child_exit_status = system(@$cmd); + $fnret = _sysret2human($child_exit_status); + return R( + $child_exit_status eq 0 ? 'OK' : ($must_succeed ? 'ERR_NON_ZERO_EXIT' : 'OK_NON_ZERO_EXIT'), + value => { + sysret => $child_exit_status + 0, + status => $fnret->value->{'status'}, + coredump => $fnret->value->{'coredump'}, + signal => $fnret->value->{'signal'}, + }, + msg => "Command exited with " . _sysret2human($child_exit_status)->msg, + ); + } + + my ($child_stdin, $child_stdout, $child_stderr); + $child_stderr = gensym; + osh_debug("about to run_cmd ['" . join("','", @$cmd) . "']"); + my $pid; + eval { $pid = open3($child_stdin, $child_stdout, $child_stderr, @$cmd); }; + if ($@) { + chomp $@; + return R('ERR_EXEC_FAILED', msg => "Couldn't exec requested command ($@)"); + } + osh_debug("waiting for child PID $pid to complete..."); + + my %output = (); + my %lineBuffer; + my $currentActive = undef; + my $currently_in_json_block = 0; + my %bytesnb; + + # always monitor our child stdout and stderr + my $select = IO::Select->new($child_stdout, $child_stderr); + binmode $child_stdin; + binmode $child_stdout; + binmode $child_stderr; + + # if some fd are closed, binmode may fail + eval { binmode STDIN; }; + eval { binmode STDOUT; }; + eval { binmode STDERR; }; + + if ($stdin_str) { + + # we have some stdin data to push, do it now + syswrite $child_stdin, $stdin_str; + close($child_stdin); + } + elsif ($expects_stdin) { + + # ... and also monitor our own stdin only if we expect it (we'll pipe it to our child's stdin) + $select->add(\*STDIN); + } + + # then, while we still have fh to monitor + while ($select->count() > 1 || ($select->count() == 1 && not $select->exists(\*STDIN))) { + + # block only for 50ms, before checking if child is dead + my @ready = $select->can_read(0.05); + + # yep, we have something to read on at least one fh + if (@ready) { + + # guarantee we're still reading this fh while it has something to say + $currentActive = $ready[0]; + my $subSelect = IO::Select->new($currentActive); + + # can_read(0) because we don't need a timeout: we KNOW there's something to read on this fh + while ($subSelect->can_read(0)) { + my $buffer; + my $nbread = sysread $currentActive, $buffer, $readsize; + + # if size 0, it means it's an EOF, if undef, it's an error + if (not $nbread) { + + # error, we'll warn and close + if (not defined $nbread) { + + # awwww, not cool at all + warn("execute(): error while sysreading($!), closing fh!"); + } + + # we got an EOF on this fh, remove it from the monitor list + $select->remove($currentActive); + + # if this is an EOF on our own STDIN, we need to close our child's STDIN + if ($currentActive->fileno == STDIN->fileno) { + close(STDIN); # we got eof on it, so close it + close($child_stdin); # and close our child stdin + } + else { + ; # eof on our child's stdout or stderr, nothing to do + } + last; + } + + # we got data, is this our child's stderr ? + if ($currentActive->fileno == $child_stderr->fileno) { + $bytesnb{'stderr'} += $nbread; + + # syswrite on our own STDERR what we received + if ($noisy_stderr) { + my $offset = 0; + while ($offset < $nbread) { + my $written = syswrite STDERR, $buffer, $readsize, $offset; + if (not defined $written) { + + # oww, abort writing for this cycle + warn("execute(): error while syswriting($!) on stderr, aborting this cycle"); + last; + } + $offset += $written; + } + } + + # mimic line-based reading (for debug, and also data will be returned to caller) + if (not $is_binary) { + + # if this is a newline, push it to our output array + if ($buffer eq $/) { + osh_debug("stderr($pid): " . $lineBuffer{'stderr'}) unless $noisy_stderr; # avoid double print + push @{$output{'stderr'}}, $lineBuffer{'stderr'}; + $lineBuffer{'stderr'} = ''; + } + + # or push it to our temp line buffer + else { + $lineBuffer{'stderr'} .= $buffer; + } + } + } + + # we got data, is this our child's stdout ? + elsif ($currentActive->fileno == $child_stdout->fileno) { + $bytesnb{'stdout'} += $nbread; + + # syswrite on our own STDOUT what we received + if ($noisy_stdout and not $is_helper) { + + # the "if is_helper" case is handled below per-line + my $offset = 0; + while ($offset < $nbread) { + my $written = syswrite STDOUT, $buffer, $readsize, $offset; + if (not defined $written) { + + # oww, abort writing for this cycle + warn("execute(): error while syswriting($!) on stdout, aborting this cycle"); + last; + } + $offset += $written; + } + } + + # mimic line-based reading (for debug, and also data will be returned to caller) + if (not $is_binary) { + if ($buffer eq $/) { + osh_debug("stdout($pid): " . $lineBuffer{'stdout'}) unless $noisy_stdout; # avoid double print + push @{$output{'stdout'}}, $lineBuffer{'stdout'}; + if ($noisy_stdout and $is_helper) { + + # in that case, we didn't noisy print each char, we wait for $/ + # then print it IF this is not the result_from_helper (json) + if ($lineBuffer{'stdout'} eq 'JSON_START') { + $currently_in_json_block = 1; + } + if (not $currently_in_json_block) { + print $lineBuffer{'stdout'} . $/; + } + if ($currently_in_json_block and $lineBuffer{'stdout'} eq 'JSON_END') { + $currently_in_json_block = 0; + } + } + $lineBuffer{'stdout'} = ''; + } + else { + $lineBuffer{'stdout'} .= $buffer; + } + } + + if ($max_stdout_bytes && $bytesnb{'stdout'} >= $max_stdout_bytes) { + + # caller got enough data, close all our child channels + $select->remove($child_stdout); + $select->remove($child_stderr); + close($child_stdin); + close($child_stdout); + close($child_stderr); + + # and also our own STDIN if we're listening for it + if ($select->exists(\*STDIN)) { + $select->remove(\*STDIN); + close(STDIN); + } + + # don't forget to push any pending data to our output buffer + push @{$output{'stdout'}}, $lineBuffer{'stdout'}; + } + } + + # we got data, is this our stdin ? + elsif ($currentActive->fileno == STDIN->fileno) { + $bytesnb{'stdin'} += $nbread; + + # we just write the data to our child's own stdin + syswrite $child_stdin, $buffer; + } + + # wow, we got data from an unknown fh ... it's not possible + else { + # ... but just in case: + require Data::Dumper; + osh_warn("unknown fh: " . Data::Dumper::Dumper($currentActive) . " with char <$buffer>"); + osh_warn(Data::Dumper::Dumper($child_stdout)); + osh_warn(Data::Dumper::Dumper($child_stderr)); + osh_warn(Data::Dumper::Dumper(\*STDIN)); + } + } + + # /guarantee + } + } + + # here, all fd went EOF (except maybe STDIN but we don't care) + # so we need to waitpid + # (might be blocking, but we have nothing to read/write anyway) + osh_debug("all fds are EOF, waiting for pid $pid indefinitely"); + waitpid($pid, 0); + my $child_exit_status = $?; + + $fnret = _sysret2human($child_exit_status); + osh_debug("cmd returned with " . $fnret->msg); + return R( + $fnret->value->{'status'} eq 0 ? 'OK' : ($must_succeed ? 'ERR_NON_ZERO_EXIT' : 'OK_NON_ZERO_EXIT'), + value => { + sysret => $child_exit_status >> 8, + stdout => $output{stdout}, + stderr => $output{stderr}, + bytesnb => \%bytesnb, + status => $fnret->value->{'status'}, + coredump => $fnret->value->{'coredump'}, + signal => $fnret->value->{'signal'}, + }, + msg => "Command exited with " . _sysret2human($child_exit_status)->msg, + ); +} + +sub result_from_helper { + my $input = shift; + + if (ref $input ne 'ARRAY') { + $input = [$input]; + } + + my $state = 1; + my @json; + foreach my $line (@$input) { + chomp; + if ($state == 1) { + if ($line eq 'JSON_START') { + + # will now capture data + @json = (); + $state = 2; + } + } + elsif ($state == 2) { + if ($line eq 'JSON_END') { + + # done capturing data, might still see a new JSON_START however + $state = 1; + } + else { + # capturing data + push @json, $line; + } + } + } + if (not @json) { + return R('ERR_HELPER_RETURN_EMPTY', msg => "The helper didn't return any data, maybe it crashed, please report to your sysadmin!"); + } + my $json_decoded; + eval { $json_decoded = decode_json(join("\n", @json)); }; + if ($@) { + return R('ERR_HELPER_RETURN_INVALID', msg => $@); + } + return R('OK', value => $json_decoded); +} + +sub helper_decapsulate { + my $value = shift; + return R($value->{'error_code'}, value => $value->{'value'}, msg => $value->{'error_message'}); +} + +sub helper { + my %params = @_; + my @command = @{$params{'cmd'} || []}; + my $expects_stdin = $params{'expects_stdin'}; + my $stdin_str = $params{'stdin_str'}; + + my $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1, is_helper => 1, expects_stdin => $expects_stdin, stdin_str => $stdin_str); + $fnret or return R('ERR_HELPER_FAILED', "something went wrong in helper script (" . $fnret->msg . ")"); + + $fnret = OVH::Bastion::result_from_helper($fnret->value->{'stdout'}); + $fnret or return $fnret; + + return OVH::Bastion::helper_decapsulate($fnret->value); +} + +1; diff --git a/lib/perl/OVH/Bastion/interactive.inc b/lib/perl/OVH/Bastion/interactive.inc new file mode 100644 index 0000000..83d7a78 --- /dev/null +++ b/lib/perl/OVH/Bastion/interactive.inc @@ -0,0 +1,231 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; +use Term::ReadLine; +use JSON; +use POSIX (); + +# autocompletion rules +my @rules; + +sub interactive { + my %params = @_; + my $realOptions = $params{'realOptions'}; + my $timeoutHandler = $params{'timeoutHandler'}; + my $self = $params{'self'}; + my $fnret; + + my $bastionName = OVH::Bastion::config('bastionName')->value(); + my $interactiveModeTimeout = OVH::Bastion::config('interactiveModeTimeout')->value() || 0; + my $slaveOrMaster = (OVH::Bastion::config('readOnlySlaveMode')->value() ? 'slave' : 'master'); + + my $term = Term::ReadLine->new('Bastion Interactive'); + my $prompt = +"\001\033[0m\033[33m$self\033[1;35m@\033[32m\002$bastionName\001\033[1;35m\002(\001\033[0m\033[36m\002$slaveOrMaster\001\033[1;35m\002)\001\033[0m\033[32m\002>\001\033[0m\002 "; + + my $prompt_non_readline = $prompt; + $prompt_non_readline =~ s=\001|\002==g; + + print < and for autocompletion. +You'll be disconnected after $interactiveModeTimeout seconds of inactivity. +EOM + + # dynamically get the list of plugins we can use + + print "Loading... "; + $fnret = OVH::Bastion::get_plugin_list(); + $fnret or return (); + my $pluginList = $fnret->value; + + my @cmdlist = ('exit', 'ssh'); + foreach my $plugin (sort keys %$pluginList) { + $fnret = OVH::Bastion::can_account_execute_plugin(plugin => $plugin, account => $self); + next if !$fnret; + + push @cmdlist, $plugin; + + # also load autocompletion rules for this plugin + if (open(my $jsonFd, '<', $pluginList->{$plugin}->{'dir'} . '/' . $plugin . '.json')) { + local $/ = undef; + my $jsonPayload = <$jsonFd>; + close($jsonFd); + my $jsonData; + eval { $jsonData = decode_json($jsonPayload); }; + if (ref $jsonData eq 'HASH' && ref $jsonData->{'interactive'} eq 'ARRAY') { + push @rules, @{$jsonData->{'interactive'}}; + } + } + } + print scalar(@cmdlist) . " commands and " . (@rules / 2) . " autocompletion rules loaded.\n\n"; + + # setup readline + + $term->ornaments(1); + my $attribs = $term->Attribs; + + $attribs->{'completion_function'} = sub { + my ($word, $line, $start) = @_; + + # word: current word being typed + # line: whole line so far + # start: cursor pos + + # avoid disconnection because the user seems to be alive + alarm($interactiveModeTimeout); + + if (!$line) { + + # autocompletion asked without anything written yet + return @cmdlist; + } + + # easter egg + if ($line eq $word and $word =~ /^con/) { + return ('configure'); + } + if ($line =~ /^conf(igure)?(\s|$)/ and ('terminal' =~ /^\Q$word\E/)) { + return ('terminal'); + } + + # /easter egg + + if ($line eq $word) { + + # first word of line, user wants completion + my @validcmds; + foreach my $cmd (@cmdlist) { + push @validcmds, $cmd if ($cmd =~ /^\Q$word\E/); + } + return @validcmds; + } + + for (my $i = 0 ; $i < @rules ; $i += 2) { + my $re = $rules[$i]; + my $item = $rules[$i + 1]; + + next unless ($line =~ m{^$re\s*$} or $line =~ m{^$re\s\Q$word\E\s*$}); + + # but wait, even if it matches, user must have the right to use this plugin, + # check that here + + my ($typedPlugin) = $line =~ m{^(\S+)}; + next unless grep { $typedPlugin eq $_ } @cmdlist; + + # if autocomplete specified, just return it + if ($item->{'ac'}) { + + # but before, check there's no magic inside, i.e. replace ACCOUNT by @account_list and GROUP by @group_list + my @autocomplete; + foreach (@{$item->{'ac'}}) { + if ($_ eq '') { + my $fnret = OVH::Bastion::get_account_list(cache => 1); + if ($fnret) { + push @autocomplete, sort keys %{$fnret->value()}; + next; + } + } + elsif ($_ eq '') { + my $fnret = OVH::Bastion::get_group_list(cache => 1, groupType => 'key'); + if ($fnret) { + push @autocomplete, sort keys %{$fnret->value()}; + next; + } + } + elsif ($_ eq '') { + my $fnret = OVH::Bastion::get_realm_list(); + if ($fnret) { + push @autocomplete, sort keys %{$fnret->value()}; + next; + } + } + elsif ($_ eq '') { + my $fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); + if ($fnret) { + push @autocomplete, 'auditor', sort keys %{$fnret->value()}; + next; + } + } + push @autocomplete, $_; + } + return @autocomplete; + } + + # else, we just print stuff ourselves + if ($item->{'pr'}) { + print "\n" . join("\n", @{$item->{'pr'}}) . "\n$prompt_non_readline$line"; + return (); + } + } + + # nothing matches, we have nothing to return + return (); + }; + + # for obscure reasons, perl signal handling code doesn't work well with readline + # unless we force them to "unsafe" mode before perl starts, which is ugly. + # so for this one, use direct sigaction() call and bypass perl signal mechanics + # cf http://stackoverflow.com/questions/13316232/perl-termreadlinegnu-signal-handling-difficulties + POSIX::sigaction POSIX::SIGALRM, POSIX::SigAction->new( + sub { + print "\n\nIdle timeout, goodbye!\n\n"; + &$timeoutHandler('TIMEOUT') if ref $timeoutHandler eq 'CODE'; + exit 1; # normally not reached + } + ); + + my $BASTION_USER = OVH::Bastion::get_user_from_env()->value; + alarm($interactiveModeTimeout); + while (defined(my $line = $term->readline($prompt))) { + alarm(0); # disable timeout + $line =~ s/^\s+|\s+$//g; + next if (length($line) == 0); # ignore empty lines + last if ($line eq 'exit' or $line eq 'quit' or $line eq 'q'); # break out of loop if asked + + $term->addhistory($line); + + if ($line =~ /^conf(i(g(u(r(e)?)?)?)?)? t(e(r(m(i(n(a(l)?)?)?)?)?)?)?$/) { + print "Nice try, but... no :)\n"; + next; + } + + { + local $ENV{'OSH_NO_INTERACTIVE'} = 1; + if ($line =~ /^ssh (.+)$/) { + system($0, '-c', "$realOptions $1"); + } + else { + system($0, '-c', "$realOptions --osh $line"); + } + } + + my (%before, %after); + $fnret = OVH::Bastion::execute(cmd => [qw{ id -G -n }]); + if ($fnret->err eq 'OK' and $fnret->value and $fnret->value->{'stdout'}) { + chomp $fnret->value->{'stdout'}->[0]; + %before = map { $_ => 1 } split(/ /, $fnret->value->{'stdout'}->[0]); + } + $fnret = OVH::Bastion::execute(cmd => ['id', '-G', '-n', $BASTION_USER]); + if ($fnret->err eq 'OK' and $fnret->value and $fnret->value->{'stdout'}) { + chomp $fnret->value->{'stdout'}->[0]; + %after = map { $_ => 1 } split(/ /, $fnret->value->{'stdout'}->[0]); + } + my @newgroups = grep { !exists $before{$_} && $_ !~ /^mfa-/ } keys %after; + if (@newgroups) { + osh_warn("IMPORTANT: You have been added to new groups since the session started."); + osh_warn("You'll need to logout/login again from this interactive session to have"); + osh_warn("your new rights applied, or you'll get sudo errors if you try to use them."); + } + } + continue { + alarm($interactiveModeTimeout); + } + alarm(0); # disable timeout + print "\n\nGoodbye!\n\n"; + return 0; +} + +1; diff --git a/lib/perl/OVH/Bastion/jail.inc b/lib/perl/OVH/Bastion/jail.inc new file mode 100644 index 0000000..644a50a --- /dev/null +++ b/lib/perl/OVH/Bastion/jail.inc @@ -0,0 +1,115 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +use constant {MINIJAIL_PATH => "/bin/minijail0",}; + +sub jailify { + my %params = @_; + + my $required = delete $params{'required'}; + my $use_sudo = delete $params{'use_sudo'}; + my $user = delete $params{'user'}; + my $group = delete $params{'group'}; + my $no_new_privs = delete $params{'no_new_privs'}; + my $set_env = delete $params{'set_env'}; + my $pid_ns = delete $params{'pid_ns'}; + my $mount_mode = delete $params{'mount_mode'}; + my $mount_ns = delete $params{'mount_ns'}; + my $pivot_root = delete $params{'pivot_root'}; + my $bind_mounts = delete $params{'bind_mounts'}; + my $mounts = delete $params{'mounts'}; + my $seccomp = delete $params{'seccomp'}; + my $uts = delete $params{'uts'}; + my $ld_preload = delete $params{'ld_preload'}; + my $dev = delete $params{'dev'}; + my $cmd = delete $params{'cmd'}; + + if (%params) { + + # this is a coding error => warn(), this'll make any test passing through it automatically fail + warn("Spurious parameter passed to jailify(): " . join(", ", keys %params)); + return R('ERR_INTERNAL', msg => "Spurious parameter passed to jailify()"); + } + + if (!$cmd || ref $cmd ne 'ARRAY') { + return R('ERR_INVALID_PARAMETER', msg => "Specified cmd is either missing or not an arrayref"); + } + + if (!-e -x OVH::Bastion::MINIJAIL_PATH) { + return R('ERR_INTERNAL', msg => "minijail not available, please warn your bastion administrator") if $required; + + # not installed and not required? just return the @cmd untouched + return R('OK_NO_CHANGE', value => $cmd); + } + + # we need to be root for several cases + if (($user || $group || $mount_ns || $pivot_root || $bind_mounts || $mounts) && ($> != 0) && !$use_sudo) { + + # this is a coding error => warn(), this'll make any test passing through it automatically fail + warn( + 'Jailify attempted with a feature needing root without being root ', + "\$user: ", + ($user // "_undef_"), + "\$group: ", + ($group // "_undef_"), + "\$mount_ns: ", + ($mount_ns // "_undef_"), + "\$pivot_root: ", + ($pivot_root // "_undef_"), + "\$bind_mounts: ", + ($bind_mounts // "_undef_"), + "\$mounts: ", + ($mounts // "_undef_"), + ); + return R('ERR_INTERNAL', msg => "Bad use of jailify(), please warn your bastion administrator"); + } + + my @jailcmd; + if ($use_sudo) { + push @jailcmd, qw{ sudo -n -u root -- }; + } + + push @jailcmd, OVH::Bastion::MINIJAIL_PATH, "--logging=stderr"; + push @jailcmd, "-u", $user if $user; + push @jailcmd, "-g", $group if $group; + push @jailcmd, "-n" if $no_new_privs; + push @jailcmd, "-v" if $mount_ns; + push @jailcmd, "-p" if $pid_ns; + push @jailcmd, "--uts" if $uts; + push @jailcmd, "-K$mount_mode" if $mount_mode; + push @jailcmd, "-d" if $dev; + push @jailcmd, "--child-ld-preload", $ld_preload if $ld_preload; + + if ($pivot_root) { + return R('ERR_INVALID_PARAMETER', msg => "Specified pivot_root ($pivot_root) is not a directory") if !-d $pivot_root; + push @jailcmd, "-P", $pivot_root; + } + + if ($seccomp) { + return R('ERR_INVALID_PARAMETER', msg => "Specified seccomp filter is not a file") if !-f -r $seccomp; + push @jailcmd, "-S", $seccomp; + } + + if (defined $bind_mounts) { + return R('ERR_INVALID_PARAMETER', msg => "Specified bind_mounts is not an arrayref") if (ref $bind_mounts ne 'ARRAY'); + push @jailcmd, "-b", $_ for (@$bind_mounts); + } + + if (defined $mounts) { + return R('ERR_INVALID_PARAMETER', msg => "Specified mounts is not an arrayref") if (ref $mounts ne 'ARRAY'); + push @jailcmd, "-k", $_ for (@$mounts); + } + + if (defined $set_env) { + return R('ERR_INVALID_PARAMETER', msg => "Specified set_env is not an arrayref") if (ref $set_env ne 'ARRAY'); + push @jailcmd, "--set-env", $_ for (@$set_env); + } + + push @jailcmd, '--', @$cmd; + + return R('OK', value => \@jailcmd); +} + +1; diff --git a/lib/perl/OVH/Bastion/log.inc b/lib/perl/OVH/Bastion/log.inc new file mode 100644 index 0000000..d7906f7 --- /dev/null +++ b/lib/perl/OVH/Bastion/log.inc @@ -0,0 +1,802 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +use DBD::SQLite; +use Time::HiRes; +use Sys::Syslog qw(); + +my $_syslog_inited = 0; # lazy init + +sub syslog { + my ($criticity, $message) = @_; + + if (not OVH::Bastion::config('enableSyslog')->value()) { + return 1; # don't do anything + } + + if (not $_syslog_inited) { + $_syslog_inited = 1; + Sys::Syslog::openlog(OVH::Bastion::config('syslogDescription')->value(), 'nofatal', OVH::Bastion::config('syslogFacility')->value()); + } + + # if message is tainted, forcefully untaint it + # or we'll crash and we won't even log that + # we crashed because it's tainted so we would + # crash trying to log the crash + ($message) = $message =~ /^(.*)$/; + eval { Sys::Syslog::syslog($criticity, $message); }; + if ($@) { + osh_warn("Couldn't syslog, report to administrator ($@)"); + } + return 1; +} + +sub syslog_close { + if ($_syslog_inited) { + Sys::Syslog::closelog(); + $_syslog_inited = 0; + } +} + +END { + syslog_close(); +} + +sub syslogFormatted { + my %params = @_; + my $criticity = $params{'criticity'} || 'info'; + my $type = $params{'type'} || 'unknown'; + my $fields = $params{'fields'}; + + if (ref $fields ne 'ARRAY') { + my $error = "bad call to syslogFormatted, invalid fields parameter"; + warn($error); # this will in turn log into syslog with the trace() thanks to the SIG{'WARN'} handler + return R('INTERNAL_ERROR', msg => $error); + } + + unshift @$fields, ['gid', ((split(/ /, $)))[0])]; + unshift @$fields, ['uid', $>]; + unshift @$fields, ['sudo_user', $ENV{'SUDO_USER'}]; + unshift @$fields, ['sysuser', OVH::Bastion::get_user_from_env()->value]; + unshift @$fields, ['ppid', getppid()]; + unshift @$fields, ['pid', $$]; + unshift @$fields, ['version', $OVH::Bastion::VERSION]; + unshift @$fields, ['uniqid', ($ENV{'UNIQID'} || '-')]; + + my @msg = ($type); + if (($type eq 'die' || $type eq 'warn') && $criticity eq 'info') { + + # in that case, "downgrade" the criticity of the message + @msg = ("$type-$criticity"); + } + + foreach my $item (@$fields) { + + # each @$fields item is a 2-dimensional array for key => value + if (ref $item ne 'ARRAY') { + my $error = "bad call to syslogFormatted, invalid item in fields (ref " . (ref $item) . ")"; + warn($error); + return R('INTERNAL_ERROR', msg => $error); + } + my ($key, $value) = @$item; + + # remove any \n in the value + $value =~ s/\n/ /g; + + # replace any \ by an escaped \ aka \\ + $value =~ s{\\}{\\\\}g; + + # replace any " by \" + $value =~ s{"}{\\"}g; + push @msg, qq{$key="$value"}; + } + + my $flatmsg = join(" ", @msg); + OVH::Bastion::syslog($criticity, $flatmsg); + return R('OK', value => $flatmsg); +} + +sub warn_syslog { + my $msg = shift; + return syslogFormatted( + type => 'code-warning', + fields => [['msg' => $msg]] + ); +} + +sub _sql_update_db { + my %params = @_; + my $sqltype = $params{'sqltype'}; + my $dbh = $params{'dbh'}; + my $sth; + my $result; + my $fnret; + + $dbh->do("PRAGMA synchronous=0"); + + # get current user_version of db + $sth = $dbh->prepare("PRAGMA user_version"); + return R('KO', msg => "getting user_version (prepare)") if not $sth; + + $result = $sth->execute(); + return R('KO', msg => "getting user_version (execute)") if not $result; + + my $user_version = $sth->fetchrow_array(); + + if ($user_version <= 0) { + + # set journal_mode, this is a no-op if already in WAL + if (!$dbh->do("PRAGMA journal_mode=WAL")) { + return R('KO', msg => "setting journal mode"); + } + + # create table + $result = $dbh->do( + $sqltype eq 'local' + ? "CREATE TABLE IF NOT EXISTS connections( + id INTEGER PRIMARY KEY, + timestamp INTEGER, + timestampusec INTEGER, + account TEXT, + cmdtype TEXT, + allowed INTEGER, + hostfrom TEXT, + ipfrom TEXT, + portfrom INTEGER, + bastionip TEXT, + bastionport INTEGER, + hostto TEXT, + ipto TEXT, + portto INTEGER, + user TEXT, + plugin TEXT, + ttyrecfile TEXT, + ttyrecsize INTEGER, + params TEXT, + timestampend INTEGER, + timestampendusec INTEGER, + returnvalue INTEGER, + comment TEXT, + uniqid TEXT)" + : "CREATE TABLE IF NOT EXISTS connections_summary( + id INTEGER PRIMARY KEY, + timestamp INTEGER, + account TEXT, + cmdtype TEXT, + allowed INTEGER, + ipfrom TEXT, + ipto TEXT, + portto INTEGER, + user TEXT, + plugin TEXT, + uniqid TEXT)" + ); + return R('KO', msg => "creating table ($sqltype)") if not $result; + + # create indexes if needed + my @columns = ( + $sqltype eq 'local' + ? qw{ timestamp ipto uniqid } + : qw{ timestamp ipto uniqid } + ); + my $table = ($sqltype eq 'local' ? "connections" : "connections_summary"); + foreach my $column (@columns) { + $dbh->do("CREATE INDEX IF NOT EXISTS idx_$column ON $table ($column)") + or return R('KO', msg => "creating index idx_$column on $table"); + } + + $dbh->do("PRAGMA user_version=1") + or return R('KO', msg => "setting user_version to 1"); + $user_version = 1; + } + + # endof version==0 + + if ($user_version == 1) { + ; # insert here future schema modifications + } + + return R('OK', msg => "sql update done"); +} + +sub _sql_log_insert_file { + + # don't call me directly, use sql_log_insert() ! + my %params = @_; + my $file = $params{'file'}; + my $account = $params{'account'}; + my $cmdtype = $params{'cmdtype'}; + my $allowed = $params{'allowed'}; + my $hostfrom = $params{'hostfrom'}; + my $ipfrom = $params{'ipfrom'}; + my $portfrom = $params{'portfrom'}; + my $bastionip = $params{'bastionip'}; + my $bastionport = $params{'bastionport'}; + my $hostto = $params{'hostto'}; + my $ipto = $params{'ipto'}; + my $portto = $params{'portto'}; + my $user = $params{'user'}; + my $plugin = $params{'plugin'}; + my $params = $params{'params'}; + my $comment = $params{'comment'}; + my $ttyrecfile = $params{'ttyrecfile'}; + my $timestamp = $params{'timestamp'}; + my $timestampusec = $params{'timestampusec'}; + my $uniqid = $params{'uniqid'}; + my $sqltype = $params{'sqltype'}; + + if ($sqltype ne 'local' and $sqltype ne 'global') { + return R('ERR_INVALID_PARAMETER', msg => "Invalid parameter sqltype"); + } + + if ($sqltype eq 'global') { + + # create the file ourselves, and set it rw rw rw (for global log) + # -journal -shm and -wal files are created with same perms by sqlite + OVH::Bastion::touch_file($file, 0666); + } + + # big db-related retry block: + # open db, set journal_mode, create table if not exists, insert data + # opendb usually always works + # journal_mode can get you a weird transient error on high concurrency (such as "failed to open database") + # same for create and insert (such as "attempted to write on readonly database") + # ... so we'll retry up to 20 times if any error arises, starting from the beginning, + # to ensure we're not locked with a readonly-$dbh for some obscure race-condition-related reason + my ($dbh, $sth, $result, $doing); + foreach my $retry (0 .. 19) { + + # if we're retrying, sleep a bit before, to ease concurrency + select(undef, undef, undef, $retry / 50 + rand() / 10) if $retry; + + # on each retry, clean those vars (undef $dbh disconnects if connected) + undef $dbh; + undef $sth; + undef $result; + undef $doing; + + # connect to db + $dbh = DBI->connect("dbi:SQLite:dbname=$file", "", "", {PrintError => 0, RaiseError => 0}); + if (!$dbh) { + $doing = "opening database"; + next; # retry + } + + my $fnret = _sql_update_db(dbh => $dbh, sqltype => $sqltype); + if (!$fnret) { + $doing = $fnret->msg; + next; # retry + } + + # preparing data insertion query + my $prepare; + my @execute; + if ($sqltype eq 'local') { + $prepare = "INSERT INTO connections +(uniqid,timestamp,timestampusec,account,cmdtype,allowed,hostfrom,ipfrom,portfrom,bastionip,bastionport,hostto,ipto,portto,user,plugin,params,comment,ttyrecfile) +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + @execute = ( + $uniqid, $timestamp, $timestampusec, $account, $cmdtype, $allowed, $hostfrom, $ipfrom, $portfrom, $bastionip, + $bastionport, $hostto, $ipto, $portto, $user, $plugin, $params, $comment, $ttyrecfile + ); + } + elsif ($sqltype eq 'global') { + $prepare = "INSERT INTO connections_summary (uniqid,timestamp,account,cmdtype,allowed,ipfrom,ipto,portto,user,plugin) +VALUES (?,?,?,?,?,?,?,?,?,?)"; + @execute = ($uniqid, $timestamp, $account, $cmdtype, $allowed, $ipfrom, $ipto, $portto, $user, $plugin); + } + + # prepare insertion on db + $sth = $dbh->prepare($prepare); + if (!$sth) { + $doing = "inserting data (prepare)"; + next; # retry + } + + # execute insertion + $result = $sth->execute(@execute); + if (!$result) { + $doing = "inserting data (execute)"; + next; # retry + } + + # if we're here, it worked, stop retrying + last; + } + + # if this is set, we probably reached max retry in previous loop without succeeding + if ($DBI::err) { + + warn_syslog("Failed after multiple retries [$sqltype] err $DBI::err while doing [$doing]: $DBI::errstr"); + return R('ERR_SQL_EXECUTE', msg => "SQL error [$sqltype] err $DBI::err while doing [$doing]: $DBI::errstr"); + } + + return R('OK', value => {id => $dbh->last_insert_id("", "", "", "")}); +} + +sub log_access_insert { + my %params = @_; + my $account = $params{'account'}; + my $uniqid = $params{'uniqid'} || $ENV{'UNIQID'}; + my $loghome = $params{'loghome'}; # only used for proxyhttp + my $custom = $params{'custom'}; # only used for proxyhttp, not pushed to sql + my $fnret; + + if (not defined $account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + my $remoteaccount = $fnret->value->{'remoteaccount'}; + + $loghome ||= $sysaccount; + + $params{'account'} = $account; + $params{'loghome'} = $loghome; + + if (not defined $uniqid) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'uniqid'"); + } + + if (not defined $params{'bastionhost'} and defined $params{'bastionip'}) { + $params{'bastionhost'} = OVH::Bastion::ip2host($params{'bastionip'})->value; + } + if (not defined $params{'hostto'} and defined $params{'ipto'}) { + $params{'hostto'} = OVH::Bastion::ip2host($params{'ipto'})->value; + } + if (not defined $params{'hostfrom'} and defined $params{'ipfrom'}) { + $params{'hostfrom'} = OVH::Bastion::ip2host($params{'ipfrom'})->value; + } + + my ($timestamp, $timestampusec) = Time::HiRes::gettimeofday(); + $params{'timestamp'} = $timestamp; + $params{'timestampusec'} = $timestampusec; + + my @localtime = localtime(time()); + my $sqlfile_global = sprintf("/home/logkeeper/global-log-%04d.sqlite", $localtime[5] + 1900); + my $sqlfile_account = + sprintf("/home/%s/%s-log-%04d%02d.sqlite", $params{'loghome'}, $remoteaccount || $loghome, $localtime[5] + 1900, $localtime[4] + 1); + + # first, log in account sql file + my ($insert_id, $db_name); + if (OVH::Bastion::config('enableAccountSqlLog')->value()) { + $params{'file'} = $sqlfile_account; + $params{'sqltype'} = 'local'; + $fnret = _sql_log_insert_file(%params); + if ($fnret) { + ($insert_id, $db_name) = ($fnret->value->{'id'}, $params{'file'}); + } + } + + # then, syslog, we'll also say if the sqlinsert failed + my @fields = ( + ['account', $account], + ['cmdtype', $params{'cmdtype'}], + ['allowed', ($params{'allowed'} ? 'true' : 'false')], + ['ip_from', $params{'ipfrom'}], + ['port_from', $params{'portfrom'}], + ['host_from', $params{'hostfrom'}], + ['ip_bastion', $params{'bastionip'}], + ['port_bastion', $params{'bastionport'}], + ['host_bastion', $params{'bastionhost'}], + ['user', $params{'user'}], + ['ip_to', $params{'ipto'}], + ['port_to', $params{'portto'}], + ['host_to', $params{'hostto'}], + ['plugin', $params{'plugin'}], + ['comment', $params{'comment'}], + ['sqlfile', ($fnret ? '' : 'ERR:') . $params{'file'}], + ['params', $params{'params'}], + ); + if (ref $custom eq 'ARRAY') { + foreach my $item (@$custom) { + push @fields, $item if (ref $item eq 'ARRAY' && @$item == 2); + } + } + $fnret = OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'open', + fields => \@fields, + ); + my $msg = '(empty)'; + $msg = $fnret->value if $fnret; + + # then, log in oldschool files + if (OVH::Bastion::config('enableAccountAccessLog')->value()) { + if (open(my $log_acc, ">>", sprintf("/home/%s/%s-log-%04d%02d.log", $params{'loghome'}, $remoteaccount || $loghome, $localtime[5] + 1900, $localtime[4] + 1))) { + print $log_acc localtime() . " $msg\n"; + close($log_acc); + } + } + if (OVH::Bastion::config('enableGlobalAccessLog')->value()) { + if (open(my $log_gen, ">>", "/home/osh.log")) { + print $log_gen localtime() . " $msg\n"; + close($log_gen); + } + } + + # then global sql + if (OVH::Bastion::config('enableGlobalSqlLog')->value()) { + $params{'file'} = $sqlfile_global; + $params{'sqltype'} = 'global'; + $fnret = _sql_log_insert_file(%params); + } + + return R('OK', value => {insert_id => $insert_id, db_name => $db_name, uniq_id => $params{'uniqid'}}); +} + +sub _sql_log_update_file { + my %params = @_; + my $file = $params{'file'}; + my $id = $params{'id'}; + my $returnvalue = $params{'returnvalue'}; + my $comment = $params{'comment'}; + my $timestampend = $params{'timestampend'}; + my $timestampendusec = $params{'timestampendusec'}; + my $ttyrecsize = $params{'ttyrecsize'}; + + my $plugin_stdout = $params{'plugin_stdout'}; + my $plugin_stderr = $params{'plugin_stderr'}; + + if (not $file or not defined $id) { + return R('ERR_MISSING_PARAMETER', msg => "Missing required parameter file or id"); + } + if (!-w $file) { + return R('ERR_FILE_NOT_FOUND', msg => "File $file should already exist"); + } + + my ($dbh, $sth, $result, $doing); + + # retry block + foreach my $retry (0 .. 19) { + + # if we're retrying, sleep a bit before, to ease concurrency + select(undef, undef, undef, $retry / 50 + rand() / 10) if $retry; + + # on each retry, clean those vars (undef $dbh disconnects if connected) + undef $dbh; + undef $sth; + undef $result; + undef $doing; + + # connect to db + $dbh = DBI->connect("dbi:SQLite:dbname=$file", "", "", {PrintError => 0, RaiseError => 0}); + if (!$dbh) { + $doing = "opening database"; + next; # retry + } + + my $prepare = "UPDATE connections SET timestampend=?, timestampendusec=?, returnvalue=?"; + my @execute = ($timestampend, $timestampendusec, $returnvalue); + + if (defined $comment) { + $prepare .= ", comment=?"; + push @execute, $comment; + } + if (defined $ttyrecsize) { + $prepare .= ", ttyrecsize=?"; + push @execute, $ttyrecsize; + } + $prepare .= " WHERE id=? AND timestampend IS NULL"; + push @execute, $id; + + # prepare insertion on db + $sth = $dbh->prepare($prepare); + if (!$sth) { + $doing = "updating data (prepare)"; + next; # retry + } + + # execute insertion + $result = $sth->execute(@execute); + if (!$result) { + $doing = "updating data (execute)"; + next; # retry + } + + # if we're here, it worked, stop retrying + last; + } + + # if this is set, we probably reached max retry in previous loop without succeeding + if ($DBI::err) { + warn("Failed after multiple retries [updating] err $DBI::err while doing [$doing]: $DBI::errstr"); + return R('ERR_SQL_EXECUTE', msg => "SQL error [updating] err $DBI::err while doing [$doing]: $DBI::errstr"); + } + + # if we have plugin stdout or stderr, log it too + if (defined $plugin_stdout or defined $plugin_stderr) { + + # retry block + foreach my $retry (0 .. 19) { + + # if we're retrying, sleep a bit before, to ease concurrency + select(undef, undef, undef, $retry / 50 + rand() / 10) if $retry; + + # on each retry, clean those vars (undef $dbh disconnects if connected) + undef $dbh; + undef $sth; + undef $result; + undef $doing; + + # connect to db + $dbh = DBI->connect("dbi:SQLite:dbname=$file", "", "", {PrintError => 0, RaiseError => 0}); + if (!$dbh) { + $doing = "opening database"; + next; # retry + } + + $sth = $dbh->prepare( + "CREATE TABLE IF NOT EXISTS plugincalls( + id INTEGER PRIMARY KEY, + connection_id INTEGER UNIQUE, + stdout TEXT, + stderr TEXT) + " + ); + if (!$sth) { + $doing = "creating plugins table (prepare)"; + next; # retry + } + $result = $sth->execute(); + if (!$result) { + $doing = "creating plugins table (execute)"; + next; # retry + } + + $sth = $dbh->prepare("INSERT INTO plugincalls (connection_id, stdout, stderr) VALUES (?,?,?)"); + if (!$sth) { + $doing = "inserting plugincall data (prepare)"; + next; # retry + } + $result = $sth->execute($id, join("\n", @{$plugin_stdout || []}), join("\n", @{$plugin_stderr || []})); + if (!$result) { + $doing = "inserting plugincall data (execute)"; + next; # retry + } + + # if we're here, it worked, stop retrying + last; + } + + # if this is set, we probably reached max retry in previous loop without succeeding + if ($DBI::err) { + + warn("Failed after multiple retries [plugins] err $DBI::err while doing [$doing]: $DBI::errstr"); + return R('ERR_SQL_EXECUTE', msg => "SQL error [plugins] err $DBI::err while doing [$doing]: $DBI::errstr"); + } + } + + return R('OK'); +} + +sub log_access_update { + my %params = @_; + my $insert_id = $params{'insert_id'}; + my $db_name = $params{'db_name'}; + my $uniq_id = $params{'uniq_id'}; + + if (not defined $params{'timestampend'} or not defined $params{'timestampendusec'}) { + ($params{'timestampend'}, $params{'timestampendusec'}) = Time::HiRes::gettimeofday(); + } + + # in any case, send a syslog even if we miss insert_id or db_name + OVH::Bastion::syslogFormatted( + criticity => 'info', + type => 'close', + fields => [ + + # TODO: in addition to the specific "on-close" fields, we should re-log here everything + # that was logged in the log_access_insert call for easier SIEM analytics + ['sysret', $params{'returnvalue'}], + ['ttyrec_size', $params{'ttyrecsize'}], + ['comment_close', $params{'comment'}], + ] + ); + + if (not defined $insert_id or not defined $db_name) { + return R('ERR_MISSING_PARAMETER', msg => "Missing required 'insert_id' or 'db_name'"); + } + + $params{'file'} = $db_name; + $params{'id'} = $insert_id; + + if (OVH::Bastion::config('enableAccountSqlLog')->value()) { + return _sql_log_update_file(%params); + } + return R('OK'); +} + +sub log_access_get { + my %params = @_; + my $account = $params{'account'}; + my $fnret; + + if (not defined $account) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'"); + } + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; + $params{'account'} = $account; + + my ($timestamp, $timestampusec) = Time::HiRes::gettimeofday(); + $params{'timestamp'} = $timestamp; + $params{'timestampusec'} = $timestampusec; + + my @localtime = localtime(time()); + + # first, check in account sql file + if (OVH::Bastion::config('enableAccountSqlLog')->value()) { + $params{'file'} = sprintf("/home/%s/%s-log-%04d%02d.sqlite", $params{'account'}, $params{'account'}, $localtime[5] + 1900, $localtime[4] + 1), $params{'sqltype'} = 'local'; + $fnret = _sql_log_fetch_from_file(%params); + return $fnret; + } + + # second, check in global sql file + +=cut not now, too slow and table columns differ + if (OVH::Bastion::config('enableGlobalSqlLog')->value()) { + $params{'file'} = sprintf("/home/logkeeper/global-log-%04d.sqlite", $localtime[5] + 1900), + $params{'sqltype'} = 'global', + $fnret = _sql_log_fetch_from_file(%params); + return $fnret; + } +=cut + + return R('ERR_DISABLED', msg => "This feature is not available"); +} + +sub _sql_log_fetch_from_file { + + # don't call me directly, use log_access_get() ! + my %params = @_; + my $file = $params{'file'}; + my $sqltype = $params{'sqltype'}; + my $limit = $params{'limit'}; + my $uniqid = $params{'uniqid'}; + my $account = $params{'account'}; + my $cmdtype = $params{'cmdtype'}; + my $allowed = $params{'allowed'}; + my $ipfrom = $params{'ipfrom'}; + my $ipto = $params{'ipto'}; + my $portto = $params{'portto'}; + my $bastionip = $params{'bastionip'}; + my $bastionport = $params{'bastionport'}; + my $user = $params{'user'}; + my $plugin = $params{'plugin'}; + my $before = $params{'before'}; + my $after = $params{'after'}; + + foreach my $param (qw{ limit before after }) { + if (defined $params{$param} and $params{$param} !~ /^\d+$/) { + return R('ERR_INVALID_PARAMETER', msg => "Expected a numeric $param"); + } + } + + my @conditions; + my @execvalues; + if ($account) { + push @conditions, "account = ?"; + push @execvalues, $account; + } + if ($cmdtype) { + push @conditions, "cmdtype = ?"; + push @execvalues, $cmdtype; + } + if (defined $allowed) { + push @conditions, "allowed = ?"; + push @execvalues, $allowed ? 1 : 0; + } + if ($uniqid) { + push @conditions, "uniqid = ?"; + push @execvalues, $uniqid; + } + if ($user) { + push @conditions, "user = ?"; + push @execvalues, $user; + } + if ($plugin) { + push @conditions, "plugin = ?"; + push @execvalues, $plugin; + } + if ($ipto) { + push @conditions, "ipto = ?"; + push @execvalues, $ipto; + } + if ($portto) { + push @conditions, "portto = ?"; + push @execvalues, $portto; + } + if ($ipfrom) { + push @conditions, "ipfrom = ?"; + push @execvalues, $ipfrom; + } + if ($bastionip) { + push @conditions, "bastionip = ?"; + push @execvalues, $bastionip; + } + if ($bastionport) { + push @conditions, "bastionport = ?"; + push @execvalues, $bastionport; + } + if ($before) { + push @conditions, "timestamp <= ?"; + push @execvalues, $before; + } + if ($after) { + push @conditions, "timestamp >= ?"; + push @execvalues, $after; + } + + my $tablename; + if ($sqltype eq 'local') { + $tablename = 'connections'; + } + elsif ($sqltype eq 'global') { + $tablename = 'connections_summary'; + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Unknown sqltype"); + } + + my $prepare = "SELECT * FROM $tablename WHERE (" . join(') AND (', @conditions) . ") ORDER BY id DESC"; + + if ($limit) { + $prepare .= " LIMIT ?"; + push @execvalues, $limit; + } + + my $openflags = {PrintError => 0, RaiseError => 0}; + if ($DBD::SQLite::VERSION ge '1.42') { + eval { $openflags->{'sqlite_open_flags'} = DBD::SQLite::OPEN_READONLY(); }; + } + + # big db-related retry block (see comment on _sql_log_insert_file) + my ($dbh, $result, $doing); + foreach my $retry (0 .. 19) { + + # if we're retrying, sleep a bit before, to ease concurrency + select(undef, undef, undef, $retry / 50 + rand() / 10) if $retry; + + # on each retry, clean those vars (undef $dbh disconnects if connected) + undef $dbh; + undef $result; + undef $doing; + + # connect to db + $dbh = DBI->connect("dbi:SQLite:dbname=$file", "", "", $openflags); + if (!$dbh) { + $doing = "opening database"; + next; # retry + } + + # fetch data + $result = $dbh->selectall_hashref($prepare, 'id', {}, @execvalues); + if (!$result) { + $doing = "querying data"; + next; # retry + } + + # if we're here, it worked, stop retrying + last; + } + + # if this is set, we probably reached max retry in previous loop without succeeding + if ($DBI::err) { + + warn("Failed after multiple retries [$sqltype] err $DBI::err while doing [$doing]: $DBI::errstr"); + return R('ERR_SQL_EXECUTE', msg => "SQL error [$sqltype] err $DBI::err while doing [$doing]: $DBI::errstr"); + } + + return R('OK', value => $result); +} + +1; diff --git a/lib/perl/OVH/Bastion/mock.inc b/lib/perl/OVH/Bastion/mock.inc new file mode 100644 index 0000000..d65d264 --- /dev/null +++ b/lib/perl/OVH/Bastion/mock.inc @@ -0,0 +1,80 @@ +package OVH::Bastion; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: + +use common::sense; + +my $_mocking_enabled = 0; +my $_mock_data; + +sub enable_mocking { + $_mocking_enabled = 1; +} + +sub is_mocking { + return $_mocking_enabled; +} + +sub set_mock_data { + die "tried to set_mock_data without enabling mocking first" unless is_mocking(); + $_mock_data = shift; +} + +sub mock_get_account_entry { + my %params = @_; + my $account = $params{'account'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + my $h = $_mock_data->{'accounts'}{$account}; + return () if !$h; + return ($account, "x", $h->{'uid'}, $h->{'gid'}, $h->{'gecos'}, "/home/$account", $OVH::Bastion::BASEPATH . "/bin/shell/osh.pl"); +} + +sub mock_get_account_accesses { + my %params = @_; + my $account = $params{'account'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + return split /:/, $_mock_data->{'accounts'}{$account}{'accesses'}; +} + +sub mock_get_account_personal_accesses { + my %params = @_; + my $account = $params{'account'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + return $_mock_data->{'accounts'}{$account}{'personal_accesses'}; +} + +sub mock_get_account_legacy_accesses { + my %params = @_; + my $account = $params{'account'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + return $_mock_data->{'accounts'}{$account}{'legacy_accesses'}; +} + +sub mock_get_group_accesses { + my %params = @_; + my $group = $params{'group'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + return $_mock_data->{'groups'}{$group}{'accesses'}; +} + +sub mock_get_account_guest_accesses { + my %params = @_; + my $group = $params{'group'}; + my $account = $params{'account'}; + + die "tried to mock without enabling mocking first" unless is_mocking(); + + return $_mock_data->{'account'}{$account}{'guest_accesses'}{$group}; +} + +1; diff --git a/lib/perl/OVH/Bastion/os.inc b/lib/perl/OVH/Bastion/os.inc new file mode 100644 index 0000000..338acc6 --- /dev/null +++ b/lib/perl/OVH/Bastion/os.inc @@ -0,0 +1,575 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +my $_sysinfo_cache; + +sub sysinfo { + if (not defined $_sysinfo_cache) { + my $fnret = OVH::Bastion::execute(cmd => [qw{ uname -sr }]); + + if ($fnret and $fnret->value and $fnret->value->{'stdout'}) { + my ($system, $release) = split(/ /, $fnret->value->{'stdout'}->[0]); + my $flavor = 'unknown'; + $flavor = 'debian' if -f "/etc/debian_version"; + $flavor = 'redhat' if -f "/etc/redhat-release"; + $_sysinfo_cache = R('OK', value => {system => $system, release => $release, flavor => $flavor}); + } + else { + $_sysinfo_cache = R('OK', value => {system => 'unknown', release => 'unknown', flavor => 'unknown'}); + } + } + return $_sysinfo_cache; +} + +sub is_linux { return R($^O eq 'linux' ? 'OK' : 'KO'); } +sub is_debian { return R(is_linux && sysinfo()->value->{'flavor'} eq 'debian' ? 'OK' : 'KO'); } +sub is_redhat { return R(is_linux && sysinfo()->value->{'flavor'} eq 'redhat' ? 'OK' : 'KO'); } + +sub is_bsd { return R($^O =~ /bsd$/ ? 'OK' : 'KO'); } +sub is_freebsd { return R($^O eq 'freebsd' ? 'OK' : 'KO'); } +sub is_openbsd { return R($^O eq 'openbsd' ? 'OK' : 'KO'); } +sub is_netbsd { return R($^O eq 'netbsd' ? 'OK' : 'KO'); } + +sub has_acls { return R((is_linux || is_freebsd) ? 'OK' : 'KO'); } + +# Helper to launch an external command that needs to modify /etc/passwd or /etc/group, such as useradd, +# userdel, groupadd, groupdel, usermod, groupmod, etc. and watch for it failing because of too much +# parallelism (as they try to lock those files). Depending on the versions, it either exits with an exit +# code of 10 (and in more rare occasions, 1), with an error message saying that it couldn't lock /etc/passwd +# or /etc/group and you should retry later. Detect that and retry silently a few times. +# +# We always return an OVH::Bastion::execute() result +sub _sys_autoretry { + my %params = @_; + + my $fnret; + for (my $try = 1 ; $try < 10 ; $try++) { + $fnret = OVH::Bastion::execute(%params); + if ( ($fnret->value && $fnret->value->{'sysret'} == 10) + || ($fnret->value && $fnret->value->{'stdout'} && grep { /retry|lock/i } @{$fnret->value->{'stdout'}}) + || ($fnret->value && $fnret->value->{'stderr'} && grep { /retry|lock/i } @{$fnret->value->{'stderr'}})) + { + # too much concurrency, sleep a bit and retry + warn_syslog('Too much concurrency on try ' + . $try + . " running command '" + . join(" ", @{$params{'cmd'} || []}) . "', " + . $fnret->msg + . ", stdout: '" + . (($fnret->value && $fnret->value->{'stdout'}) ? $fnret->value->{'stdout'}->[0] : '(null)') . "'" + . ", stderr: '" + . (($fnret->value && $fnret->value->{'stderr'}) ? $fnret->value->{'stderr'}->[0] : '(null)') + . "'"); + osh_info("This is taking longer than usually, please be patient..."); + sleep(rand(5) + (5 * $try)); + } + else { + # any other error or success, return + return $fnret; + } + } + + # failed too many times, log the detailed error in our system log, warn the user and return what we have + warn_syslog('Too much concurrency running command "' . join(" ", $params{'cmd'}) . '", returned ' . $fnret->msg . ', gave up'); + warn "Couldn't apply modifications (concurrency problem?)"; + return $fnret; +} + +sub sys_useradd { + my %params = @_; + my $user = delete $params{'user'}; + + if (not $user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'user'"); + } + + my @cmd; + if (exists $params{'uid'}) { + push @cmd, ('-u', delete $params{'uid'}); + } + if (exists $params{'gid'}) { + push @cmd, ('-g', delete $params{'gid'}); + } + if (exists $params{'home'}) { + push @cmd, ('-d', delete $params{'home'}); + } + if (exists $params{'comment'}) { + push @cmd, ('-c', delete $params{'comment'}); + } + if (exists $params{'shell'}) { + my $shell = delete $params{'shell'}; + if (not defined $shell) { + + # we want a shell that exists and prevents login + LOOP: foreach my $dir (qw{ /usr/sbin /usr/bin /sbin /bin }) { + foreach my $exe (qw{ nologin false }) { + if (-x "$dir/$exe") { + $shell = "$dir/$exe"; + last LOOP; + } + } + } + } + push @cmd, ('-s', $shell); + } + + if (is_freebsd()) { + @cmd = ('pw', 'useradd', '-n', $user, '-m', @cmd); + } + elsif (is_bsd()) { # at least obsd and fbsd + # to avoid this useradd msg: + # useradd: Password `*' is invalid: setting it to `*************' + @cmd = ('useradd', '-p', '*************', '-m', @cmd, $user); + } + else { + @cmd = ('useradd', '-p', '*', '-m', @cmd, $user); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_groupadd { + my %params = @_; + my $group = delete $params{'group'}; + + if (not $group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'group'"); + } + + my @cmd; + if ($params{'gid'}) { + push @cmd, ('-g', delete $params{'gid'}); + } + + if (is_freebsd()) { + @cmd = ('pw', 'groupadd', '-n', $group, @cmd); + } + else { + @cmd = ('groupadd', @cmd, $group); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_userdel { + my %params = @_; + my $user = delete $params{'user'}; + + if (not $user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'user'"); + } + + my @cmd; + if (is_freebsd()) { + @cmd = ('pw', 'userdel', '-n', $user,); + } + elsif (is_netbsd() || is_openbsd()) { + + # main user group is never auto-removed, so we'll do it but only + # if the name of the user is the same than the name of its primary group + my ($fnret, $maingroup); + $fnret = OVH::Bastion::execute(cmd => ['id', '-g', '-n', $user], %params); + if ($fnret->err eq 'OK' and $fnret->value and $fnret->value->{'stdout'}) { + $maingroup = $fnret->value->{'stdout'}->[0]; + } + + if (defined $maingroup && $user eq $maingroup) { + + # okay, maingroup == user, so delete the user first, then the corresponding group + $fnret = OVH::Bastion::execute(cmd => ['userdel', $user], %params); + if ($fnret->err eq 'OK') { + @cmd = ('groupdel', $user); + } + } + else { + # hmm, either the main group is not the same as the user, or we can't tell, + # so just delete the user anyway + @cmd = ('userdel', $user); + } + } + else { + @cmd = ('userdel', $user); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_groupdel { + my %params = @_; + my $group = delete $params{'group'}; + + if (not $group) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'group'"); + } + + my @cmd; + if (is_freebsd()) { + @cmd = ('pw', 'groupdel', '-n', $group,); + } + else { + @cmd = ('groupdel', $group); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_addmembertogroup { + my %params = @_; + my $user = delete $params{'user'}; + my $group = delete $params{'group'}; + + if (not $group or not $user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'group' or 'user'"); + } + + if (is_openbsd() || is_netbsd()) { + my $fnret = OVH::Bastion::execute(cmd => ["groups", $user], must_succeed => 1); + my @stdout = @{$fnret->value->{'stdout'} || []}; + my @cur = split(/ /, $stdout[0]); + return R('ERR_SYSTEM_LIMIT_REACHED') if @cur >= 16; + } + + my @cmd; + if (is_freebsd()) { + @cmd = ('pw', 'groupmod', '-n', $group, '-m', $user); + } + elsif (is_bsd()) { # openbsd and netbsd: ok + @cmd = ('usermod', '-G', $group, $user); + } + else { + @cmd = ('usermod', '-a', '-G', $group, $user); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_delmemberfromgroup { + my %params = @_; + my $user = delete $params{'user'}; + my $group = delete $params{'group'}; + + if (not $group or not $user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'group' or 'user'"); + } + + my @cmd; + delete $params{'cmd'}; # security + if (is_freebsd()) { + @cmd = ('pw', 'groupmod', '-n', $group, '-d', $user); + } + elsif (is_debian()) { + @cmd = ('deluser', $user, $group); + } + elsif (is_openbsd() || is_linux()) { + + # geez. those guys are complicated. + # first get the list of all groups user is a member of + my $fnret = OVH::Bastion::execute(cmd => ['id', '-G', '-n', $user], %params); + if ($fnret->err eq 'OK' and $fnret->value and $fnret->value->{'stdout'}) { + my %groups = map { $_ => 1 } split(/ /, $fnret->value->{'stdout'}->[0]); + + # remove the group we want to remove from the list + delete $groups{$group}; + + # we must also remove the primary group from the list + # because -S (openbsd) / -G (linux) is only for secondary groups + $fnret = OVH::Bastion::execute(cmd => ['id', '-g', '-n', $user], %params); + if ($fnret->err eq 'OK' and $fnret->value and $fnret->value->{'stdout'}) { + my $primary = $fnret->value->{'stdout'}->[0]; + delete $groups{$primary}; + + # now prepare the 3rd and last command + @cmd = ('usermod', is_openbsd() ? '-S' : '-G', join(',', keys %groups), $user); + } + else { + return R('ERR_INTERNAL', msg => "Couldn't remove user from group (unknown primary group)"); + } + } + else { + return R('ERR_INTERNAL', msg => "Couldn't remove user from group (couldn't get group list)"); + } + } + elsif (is_netbsd()) { + + # NetBSD has no way of removing a user from a group without + # manually patching /etc/group... eew :( + my $contents; + if (open(my $fh, '<', '/etc/group')) { + while (<$fh>) { + if (/^\Q$group\E:/) { + s/([:,])\Q$user\E(,|$)/$1/; + s/,$//; + } + $contents .= $_; + } + close($fh); + if (open(my $fh, '>', '/etc/group')) { + print $fh $contents; + close($fh); + } + else { + return R('ERR_INTERNAL', msg => "Couldn't open group file for writing ($!)"); + } + } + else { + return R('ERR_INTERNAL', msg => "Couldn't open group file for reading ($!)"); + } + return R('OK'); # we're done, we've no other command to execute + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_changepassword { + my %params = @_; + my $user = delete $params{'user'}; + my $password = delete $params{'password'}; + + if (!$user || !$password) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'user' or 'password'"); + } + + my @cmd; + my $stdin_str; + + if (is_linux()) { + @cmd = ('chpasswd'); + $stdin_str = "$user:$password"; + } + elsif (is_freebsd()) { + @cmd = ('pw', 'usermod', $user, '-h', '0'); + $stdin_str = $password; + } + elsif (is_openbsd() || is_netbsd()) { + my $fnret; + if (is_openbsd()) { + $fnret = OVH::Bastion::execute(cmd => ["encrypt"], stdin_str => $password, must_succeed => 1); + } + else { + # netbsd + $fnret = OVH::Bastion::execute(cmd => ["pwhash", $password], must_succeed => 1); + } + $fnret or return $fnret; + my ($encrypted) = $fnret->value->{'stdout'}->[0] =~ m{^([\$a-zA-Z0-9./]+)}; + @cmd = ('usermod', '-p', $encrypted, $user); + } + else { + return R('ERR_NOT_IMPLEMENTED'); + } + + $params{'stdin_str'} = $stdin_str if defined $stdin_str; + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + my $fnret = _sys_autoretry(%params); + delete $ENV{'EDITOR'}; + return $fnret; +} + +sub sys_neutralizepassword { + my %params = @_; + my $user = delete $params{'user'}; + + if (!$user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'user'"); + } + + my @cmd; + my $stdin_str; + + if (is_linux()) { + @cmd = ('chpasswd', '-e'); + $stdin_str = "$user:*"; + } + elsif (is_freebsd()) { + @cmd = ('chpass', '-p', '*', $user); + } + elsif (is_openbsd() || is_netbsd()) { + @cmd = ('usermod', '-p', '*' x 13, $user); + } + else { + return R('ERR_NOT_IMPLEMENTED'); + } + + $params{'stdin_str'} = $stdin_str if defined $stdin_str; + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + my $fnret = _sys_autoretry(%params); + delete $ENV{'EDITOR'}; + return $fnret; +} + +sub sys_setpasswordpolicy { + my %params = @_; + my $user = delete $params{'user'}; + my $expireDays = delete $params{'expireDays'}; + my $inactiveDays = delete $params{'inactiveDays'}; + my $minDays = delete $params{'minDays'}; + my $maxDays = delete $params{'maxDays'}; + my $warnDays = delete $params{'warnDays'}; + + if (!$user) { + return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory parameter 'user' or 'password'"); + } + + my @cmd; + + if (is_linux()) { + @cmd = ('chage'); + if (defined $expireDays) { + require POSIX; + push @cmd, '--expiredate', POSIX::strftime("%Y-%m-%d", localtime(time() + 86400 * $expireDays)); + } + push @cmd, '--inactive', $inactiveDays if defined $inactiveDays; + push @cmd, '--mindays', $minDays if defined $minDays; + push @cmd, '--maxdays', $maxDays if defined $maxDays; + push @cmd, '--warndays', $warnDays if defined $warnDays; + push @cmd, $user; + if (@cmd == 1) { + return R('ERR_MISSING_PARAMETER', msg => "No password policy to set"); + } + } + else { + return R('OK_IGNORED'); + } + + $params{'cmd'} = \@cmd; + $params{'must_succeed'} = 1; + return _sys_autoretry(%params); +} + +sub sys_getpasswordinfo { + my %params = @_; + my $user = delete $params{'user'}; + my $fnret; + + my %ret; + if (is_linux()) { + $fnret = OVH::Bastion::execute(cmd => ['getent', 'shadow', $user]); + $fnret or return $fnret; + return R('KO_NOT_FOUND') if ($fnret->value->{'sysret'} != 0); + return R('ERR_CANNOT_PARSE_SHADOW') if ($fnret->value->{'stdout'}->[0] !~ m{^([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*)$}); + %ret = (user => $1, password => $2, epoch_changed_days => $3, min_days => $4, max_days => $5, warn_days => $6, inactive_days => $7, epoch_disabled_days => $8); + + } + elsif (is_bsd()) { + + # bsd has nothing to get "shadow" info without reading it ourselves... + if (open(my $masterfd, '<', '/etc/master.passwd')) { + my @lines = <$masterfd>; + close($masterfd); + my @userlines = grep { /^\Q$user\E:/ } @lines; + return R('KO_NOT_FOUND') if (@userlines != 1); + return R('ERR_CANNOT_PARSE_SHADOW') if ($userlines[0] !~ m{^([^:]*):([^:]*)}); + %ret = (user => $1, password => $2); + } + else { + return R('ERR_CANNOT_READ_FILE', msg => "Couldn't open /etc/master.passwd: $!"); + } + } + + if ($ret{'password'} =~ /^[*!]/) { + $ret{'password'} = 'locked'; + } + elsif (length($ret{'password'}) == 0) { + $ret{'password'} = 'empty'; + } + else { + $ret{'password'} = 'set'; + } + require POSIX; + $ret{'date_changed_timestamp'} = 86400 * delete($ret{'epoch_changed_days'}) + 0; + $ret{'date_changed'} = $ret{'date_changed_timestamp'} ? POSIX::strftime("%Y-%m-%d", localtime($ret{'date_changed_timestamp'})) : undef; + $ret{'min_days'} += 0; + $ret{'max_days'} += 0; + $ret{'max_days'} = -1 if $ret{'max_days'} >= 9999; + $ret{'warn_days'} += 0; + $ret{'inactive_days'} = -1 if $ret{'inactive_days'} eq ''; + $ret{'inactive_days'} += 0; + $ret{'date_disabled_timestamp'} = 86400 * delete($ret{'epoch_disabled_days'}) + 0; + $ret{'date_disabled'} = $ret{'date_disabled_timestamp'} ? POSIX::strftime("%Y-%m-%d", localtime($ret{'date_disabled_timestamp'})) : undef; + return R('OK', value => \%ret); +} + +sub sys_getsudoersfolder { + my $sudoers_dir = "/etc/sudoers.d"; + if (-d "/usr/local/etc/sudoers.d" && !-d "/etc/sudoers.d") { + $sudoers_dir = "/usr/local/etc/sudoers.d"; # FreeBSD + } + if (-d "/usr/pkg/etc/sudoers.d" && !-d "/etc/sudoers.d") { + $sudoers_dir = "/usr/pkg/etc/sudoers.d"; # NetBSD + } + return $sudoers_dir; +} + +sub sys_setfacl { + my %params = @_; + my $default = $params{'default'}; + my $clear = $params{'clear'}; + my $delete = $params{'delete'}; + my $target = $params{'target'}; + my $perms = $params{'perms'}; + + return R('OK_IGNORED') if (!is_linux && !is_freebsd); + my @cmd; + my $fnret; + + # setfacl +X doesn't exist under FreeBSD + $perms =~ s/X/x/g if is_freebsd(); + + if ($default && !$delete && !$clear && is_freebsd()) { + + # FreeBSD refuses to set a default ACL concerning a user/group that is different + # from the owner if there's not already a default ACL set for the owner/group/other + # so silently set one to the same perms that the current UNIX perms of the target when this is the case + $fnret = OVH::Bastion::execute(cmd => ['getfacl', '-d', '-q', $target], must_succeed => 1, noisy_stderr => 1); + $fnret or return R('ERR_GETFACL_FAILED_FREEBSD_1', msg => "Couldn't get the current default ACL"); + if (@{$fnret->value->{'stdout'}} == 0) { + + # no default acl set, we must set one, to do this, get the current (non-ACL) perms + $fnret = OVH::Bastion::execute(cmd => ['getfacl', '-q', $target], must_succeed => 1, noisy_stderr => 1); + $fnret or return R('ERR_GETFACL_FAILED_FREEBSD_2', msg => "Couldn't get the current ACL"); + my @perms; + foreach (@{$fnret->value->{'stdout'}}) { + chomp; + /^((user|group|other)::...)$/ or next; + push @perms, $1; # untaint + } + if (@perms != 3) { + return R('ERR_GETFACL_PARSE_FAILED_FREEBSD', msg => "Couldn't parse getfacl output to set prerequisite default ACL"); + } + + # apply the default ACL + @cmd = ('setfacl', '-d', '-m', join(',', @perms), $target); + $fnret = OVH::Bastion::execute(cmd => \@cmd, must_succeed => 1, noisy_stderr => 1); + $fnret or return R('ERR_SETFACL_FAILED_FREEBSD', msg => "Couldn't set the prerequisite default ACL"); + } + } + + @cmd = ('setfacl'); + if ($default) { push @cmd, '-d' } + if ($clear) { push @cmd, '-b' } + if ($delete) { push @cmd, '-x' } + elsif ($perms) { push @cmd, '-m' } + push @cmd, $perms if $perms; + push @cmd, $target; + + $fnret = OVH::Bastion::execute(cmd => \@cmd, must_succeed => 1, noisy_stderr => 1); + $fnret or return R('ERR_SETFACL_FAILED', msg => "Couldn't set the requested ACL"); + return R('OK'); +} + +1; diff --git a/lib/perl/OVH/Bastion/password.inc b/lib/perl/OVH/Bastion/password.inc new file mode 100644 index 0000000..c3887bd --- /dev/null +++ b/lib/perl/OVH/Bastion/password.inc @@ -0,0 +1,156 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; +use Digest::SHA qw{ hmac_sha256 }; + +# PBKDF2 like - HMAC-SHA256 - return 256 bits key +sub _get_key_from_password { + my %params = @_; + my $password = $params{'password'}; + + if (not $password) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'password'"); + } + + my $salt = 'JPYWrLpoXcXFA46m9DUI5z02SqUd2baG'; + my $iterations = 10_000; + + my $hash = hmac_sha256($salt . pack('N', 0), $password); + my $result = $hash; + + for my $iter (2 .. $iterations) { + $hash = hmac_sha256($hash, $password); + $result ^= $hash; + } + + return R('OK', value => $result); +} + +# generate a fixed salt given (a password AND a nonce AND a salt len) +sub _get_salt_for_password { + my %params = @_; + my $password = $params{'password'}; + my $nonce = $params{'nonce'} || $password; + my $len = $params{'len'} || 4; + + if ($len > 16) { + return R('ERR_INVALID_PARAMETER', msg => "Expected a len <= 16"); + } + + # get a derived key from what we've been given + my $fnret = _get_key_from_password(password => $password . $nonce . $len . $nonce . $password); + $fnret or return $fnret; + + # then generate the salt from the key + my @u16 = unpack('S*', $fnret->value); + my $s; + foreach my $i (1 .. $len) { + my $r = $u16[$i - 1] % (10 + 26 + 26); + if ($r < 10) { $s .= $r } + elsif ($r < 36) { $s .= chr($r - 10 + ord('a')) } + else { $s .= chr($r - 36 + ord('A')) } + } + return R('OK', value => $s); +} + +sub get_hashes_from_password { + my %params = @_; + my $password = $params{'password'}; + + if (not $password) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'password'"); + } + + my %ret; + $ret{'md5crypt'} = crypt($password, '$1$' . _get_salt_for_password(password => $password, nonce => '$1', len => 4)->value . '$'); + $ret{'sha256crypt'} = crypt($password, '$5$' . _get_salt_for_password(password => $password, nonce => '$5', len => 8)->value . '$'); + $ret{'sha512crypt'} = crypt($password, '$6$' . _get_salt_for_password(password => $password, nonce => '$6', len => 8)->value . '$'); + + # some OSes have a broken crypt() that doesn't generate invalid hashes, undef those + $ret{'sha256crypt'} = undef if $ret{'sha256crypt'} !~ m{^\$5\$}; + $ret{'sha512crypt'} = undef if $ret{'sha512crypt'} !~ m{^\$6\$}; + + return R('OK', value => \%ret); +} + +sub get_hashes_list { + my %params = @_; + my $context = $params{'context'}; + my $group = $params{'group'}; + my $account = $params{'account'}; + + my $fnret; + my $shortGroup; + my $homepass; + my $passfile; + + if ($context eq 'group') { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); + $fnret or return $fnret; + $group = $fnret->value->{'group'}; + $shortGroup = $fnret->value->{'shortGroup'}; + + $homepass = "/home/$group/pass"; + $passfile = "$homepass/$shortGroup"; + } + elsif ($context eq 'account') { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + $account = $fnret->value->{'account'}; + + $homepass = "/home/$account/pass"; + $passfile = "$homepass/$account"; + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Expected a context of 'group' or 'account'"); + } + + my @ret; + foreach my $inc ('', 1 .. 99) { + my $currentname = $passfile; + $currentname .= ".$inc" if (length($inc) > 0); + if (open(my $fdin, '<', $currentname)) { + my %current; + my $pass = <$fdin>; + close($fdin); + chomp $pass; + + $fnret = OVH::Bastion::get_hashes_from_password(password => $pass); + undef $pass; + $fnret or return $fnret; + + my $desc = (length($inc) == 0 ? "Current password" : "Fallback password $inc"); + + my %metadata; + if (open(my $metadatafd, '<', "$currentname.metadata")) { + while (<$metadatafd>) { + chomp; + m{^([A-Z0-9_-]+)=(.+)$} or next; + my ($key, $val) = (lc($1), $2); + $metadata{$key} = $val; + + # int-ize if it's an int, for json: + $metadata{$key} += 0 if ($val =~ /^\d+$/); + } + close($metadatafd); + } + if (%metadata) { + $current{'metadata'} = \%metadata; + $desc .= " created at $metadata{'creation_time'} by $metadata{'created_by'}"; + } + + $current{'description'} = $desc; + $current{'hashes'} = $fnret->value; + + push @ret, \%current; + } + else { + last; + } + } + + return R('OK', value => \@ret); +} + +1; diff --git a/lib/perl/OVH/Bastion/ssh.inc b/lib/perl/OVH/Bastion/ssh.inc new file mode 100644 index 0000000..2a1aa48 --- /dev/null +++ b/lib/perl/OVH/Bastion/ssh.inc @@ -0,0 +1,823 @@ +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +package OVH::Bastion; + +use common::sense; + +use File::Temp; +use Fcntl qw{ :mode :DEFAULT }; + +sub verify_piv { + my %params = @_; + my $key = $params{'key'}; + my $keyCertificate = $params{'keyCertificate'}; + my $attestationCertificate = $params{'attestationCertificate'}; + + my $fnret; + $fnret = OVH::Bastion::execute(must_succeed => 1, cmd => ['ovh-yubico-piv-checker', $key, $attestationCertificate, $keyCertificate]); + if (!$fnret || $fnret->value->{'sysret'} != 0) { + return R('KO_INVALID_PIV', "This SSH key failed PIV verification"); + } + my $keyPivInfo; + eval { + require JSON; + $keyPivInfo = JSON::decode_json($fnret->value->{'stdout'}->[0]); + }; + return R('OK', value => $keyPivInfo); # keyPivInfo can be undef if JSON decode failed, but the key is still a valid one +} + +sub get_authorized_keys_from_file { + my %params = @_; + my $file = $params{'file'}; + my $includeInvalid = $params{'includeInvalid'}; # also include keys that fail the sanity check + my $includePivDisabled = $params{'includePivDisabled'}; # also include keys that are commented with # NOTPIV + my $fnret; + my @result; + + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'file'") if not $file; + + if (open(my $fh, '<', $file)) { + my $i = 0; + my $state = 0; + my $pivAttestationCertificate; + my $pivKeyCertificate; + my $info; + while (my $line = <$fh>) { + $i++; + chomp $line; + next if $line =~ /^\s*$/; # ignore empty lines + if ($line =~ /^# INFO: (.+)/) { + $info = $1; + next; + } + elsif ($line eq '# PIV ATTESTATION CERTIFICATE:') { + $state = 1; + next; + } + elsif ($line eq '# PIV KEY CERTIFICATE:') { + $state = 2; + next; + } + elsif ($line =~ /^# (.+)/ && $state == 1) { + + # state 1: we're currently reading an attestation cert + $pivAttestationCertificate .= $1 . "\n"; + next; + } + elsif ($line =~ /^# (.+)/ && $state == 2) { + + # state 2: we're currently reading a key cert + $pivKeyCertificate .= $1 . "\n"; + next; + } + $state = 0; + my $pivDisabled = 0; + if ($includePivDisabled && $line =~ /^# NOTPIV (.+)/) { + $line = $1; + $pivDisabled = 1; + } + next if $line =~ /^\s*#/; # ignore comments + $fnret = OVH::Bastion::get_ssh_pub_key_info(pubKey => $line, way => 'ingress'); + if (grep { $fnret->err eq $_ } qw{ KO_NOT_A_KEY KO_PRIVATE_KEY ERR_INTERNAL }) { + next unless $includeInvalid; + $fnret->{'value'} = {} if not ref $fnret->{'value'} eq 'HASH'; + } + next if ($fnret->err eq 'KO_NOT_A_KEY' && !$fnret->value->{'line'}); # skip empty lines + my $key = $fnret->value; + + $key->{'err'} = $fnret->err; + $key->{'index'} = $i; + $key->{'pivAttestationCertificate'} = $pivAttestationCertificate if $pivAttestationCertificate; + $key->{'pivKeyCertificate'} = $pivKeyCertificate if $pivKeyCertificate; + $key->{'info'} = $info if $info; + if ($pivAttestationCertificate && $pivKeyCertificate) { + $fnret = OVH::Bastion::verify_piv(key => $key->{'line'}, attestationCertificate => $pivAttestationCertificate, keyCertificate => $pivKeyCertificate); + $key->{'isPiv'} = ($fnret ? 1 : 0); + $key->{'pivInfo'} = $fnret->value if $fnret; + } + if ($includePivDisabled && $pivDisabled) { + $key->{'pivDisabled'} = 1; + } + push @result, $key; + undef $info; + undef $pivAttestationCertificate; + undef $pivKeyCertificate; + } + close($fh); + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open $file ($!)"); + } + return R('OK', value => \@result); +} + +sub put_authorized_keys_to_file { + my %params = @_; + my $file = $params{'file'}; + my $data = $params{'data'}; + my $account = $params{'account'}; # we need it to apply the proper rights + my $fnret; + + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'file'") if not $file; + return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'data'") if not $data; + if (ref $data ne 'ARRAY') { + return R('ERR_INVALID_PARAMETER', msg => "Argument 'data' should be an array"); + } + + my $newFile = $file . ".new"; + if (open(my $fh, '>', $newFile)) { + foreach my $key (@$data) { + if ($key->{'info'}) { + print $fh "# INFO: " . $key->{'info'} . "\n"; + } + if ($key->{'pivAttestationCertificate'}) { + my $toWrite = "PIV ATTESTATION CERTIFICATE:\n"; + $toWrite .= $key->{'pivAttestationCertificate'}; + $toWrite =~ s/^/# /mg; + chomp $toWrite; + print $fh $toWrite . "\n"; + } + if ($key->{'pivKeyCertificate'}) { + my $toWrite = "PIV KEY CERTIFICATE:\n"; + $toWrite .= $key->{'pivKeyCertificate'}; + $toWrite =~ s/^/# /mg; + chomp $toWrite; + print $fh $toWrite . "\n"; + } + print $fh $key->{'line'} . "\n\n"; + } + close($fh); + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open $file ($!)"); + } + + if ($account) { + my (undef, undef, $uid, $gid) = getpwnam($account); + chown $uid, $gid, $newFile; + } + chmod 0644, $newFile; + + if (!rename $file, $file . '.backup-' . time() . '-' . $$) { + return R('ERR_RENAME_FAILED', msg => "Couldn't rename old authorized keys file, aborting"); + } + if (!rename $newFile, $file) { + return R('ERR_RENAME_FAILED', msg => "Couldn't replace authorized keys file, account left in a locked-out state!"); + } + return R('OK'); +} + +sub get_ssh_pub_key_info { + my %params = @_; + my $pubKey = $params{'pubKey'}; + my $file = $params{'file'}; + my $noexec = $params{'noexec'}; + my $way = $params{'way'}; + my $fnret; + + if (not $way) { + return R('ERR_MISSING_PARAMETER', msg => "Missing argument way in get_ssh_pub_key_info"); + } + if ($way ne 'ingress' && $way ne 'egress') { + return R('ERR_INVALID_PARAMETER', msg => "Expected ingress or egress for argument way in get_ssh_pub_key_info"); + } + $way = ucfirst($way); + + $pubKey =~ s/[\r\n]//g; + + if ($file) { + if (open(my $fh, '<', $file)) { + $pubKey = <$fh>; + close($fh); + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open specified file ($!)"); + } + } + + # some little sanity check + if ($pubKey =~ /PRIVATE KEY/) { + + # n00b check + return R('KO_PRIVATE_KEY'); + } + + if ($pubKey !~ m{^\s*((\S+)\s+)?(ssh-dss|ssh-rsa|ecdsa-sha\d+-nistp\d+|ssh-ed\d+)\s+([a-zA-Z0-9/=+]+)(\s+(.{1,128}))?$} + || length($pubKey) > 3000) + { + return R('KO_NOT_A_KEY', value => {line => $pubKey}); + } + my ($prefix, $typecode, $base64, $comment) = ($2, $3, $4, $6); + my $line = "$typecode $base64"; + $prefix = '' if not defined $prefix; + $line .= " " . $comment if $comment; + $line = $prefix . " " . $line if $prefix; + my @fromList; + if ($prefix =~ /^from=["']([^ "']+)/) { + @fromList = split /,/, $1; + } + my %return = ( + prefix => $prefix, + typecode => $typecode, + base64 => $base64, + comment => $comment, + line => $line, + fromList => \@fromList, + ); + + # put that in a tempfile for ssh-keygen inspection + if (not $noexec) { + my $fh = File::Temp->new(UNLINK => 1); + my $filename = $fh->filename; + print {$fh} $typecode . " " . $base64; + close($fh); + $fnret = OVH::Bastion::execute(cmd => ['ssh-keygen', '-l', '-f', $filename]); + if ($fnret->is_err || not $fnret->value || ($fnret->value->{'sysret'} != 0 && $fnret->value->{'sysret'} != 1)) { + + # sysret == 1 means ssh-keygen didn't recognize this key, handled below. + return R('ERR_SSH_KEYGEN_FAILED', msg => "Couldn't read the fingerprint of $filename (" . $fnret->msg . ")"); + } + my $sshkeygen; + if ($fnret->err eq 'OK') { + $sshkeygen = $fnret->value->{'stdout'}->[0]; + chomp $sshkeygen; + } + +=cut +2048 01:c0:37:5e:b4:bf:00:b6:ef:d3:65:a7:5c:60:b1:81 john@doe (RSA) +521 af:84:cd:70:34:64:ca:51:b2:17:1a:85:3b:53:2e:52 john@doe (ECDSA) +1024 c0:4d:f7:bf:55:1f:95:59:be:7e:50:47:e4:81:c3:6a john@doe (DSA) +256 SHA256:Yggd7VRRbbivxkdVwrdt0HpqKNylMK91nNIU+RxndTI john@doe (ED25519) +=cut + + if (defined $sshkeygen and $sshkeygen =~ /^(\d+)\s+(\S+)\s+(.+)\s+\(([A-Z0-9]+)\)$/) { + my ($size, $fingerprint, $comment2, $family) = ($1, $2, $3, $4); + $return{'size'} = $size + 0; + $return{'fingerprint'} = $fingerprint; + $return{'family'} = $family; + my @blacklistfiles = qw{ DSA-1024 DSA-2048 RSA-1024 RSA-2048 RSA-4096 }; + if (grep { "$family-$size" eq $_ } @blacklistfiles) { + + # check for vulnkeys + my $file = '/usr/share/ssh/blacklist.' . $family . '-' . $size; + if (-r $file && open(my $fh_blacklist, '<', $file)) { + my $shortfp = $fingerprint; + $shortfp =~ s/://g; + $shortfp =~ s/^.{12}//; + + #print "looking for shortfingerprint=$shortfp...\n"; + while (<$fh_blacklist>) { + /^\Q$shortfp\E$/ or next; + close($fh_blacklist); + return R('KO_VULNERABLE_KEY', value => \%return); + } + close($fh_blacklist); + } + } + + # check allowed algos and key size + my $allowedSshAlgorithms = OVH::Bastion::config("allowed${way}SshAlgorithms"); + my $minimumRsaKeySize = OVH::Bastion::config("minimum${way}RsaKeySize"); + if ($allowedSshAlgorithms && not grep { lc($return{'family'}) eq $_ } @{$allowedSshAlgorithms->value}) { + return R('KO_FORBIDDEN_ALGORITHM', value => \%return); + } + if ($minimumRsaKeySize && lc($return{'family'}) eq 'rsa' && $minimumRsaKeySize->value > $return{'size'}) { + return R('KO_KEY_SIZE_TOO_SMALL', value => \%return); + } + return R('OK', value => \%return); + } + else { + return R('KO_NOT_A_KEY', value => \%return); + } + } + else { + # noexec is set, caller doesn't want us to call ssh-keygen + return R('OK', value => \%return); + } + return R('ERR_INTERNAL', value => \%return); +} + +sub is_valid_public_key { + my %params = @_; + my $pubKey = $params{'pubKey'}; + my $noexec = $params{'noexec'}; # don't run ssh-keygen in get_ssh_pub_key_info + my $way = $params{'way'}; + + my $fnret = R('KO_NOT_A_KEY', msg => "This is not a key", silent => 1); + + if (defined $pubKey) { + $pubKey =~ tr/\r\n//d; + $fnret = OVH::Bastion::get_ssh_pub_key_info(pubKey => $pubKey, noexec => $noexec, way => $way); + } + + if ($fnret->err eq 'KO_PRIVATE_KEY') { + + # n00b check + $fnret->{'msg'} = <err eq 'KO_NOT_A_KEY') { + $fnret->{'msg'} = <= 2048 bits) +and if supported by the OS, ECDSA and Ed25519. +EOS + } + elsif ($fnret->err eq 'KO_VULNERABLE_KEY') { + $fnret->{'msg'} = <err eq 'KO_KEY_SIZE_TOO_SMALL') { + $fnret->{'msg'} = "This is too small. And sorry, but, yes, size DOES matter. Please re-generate a bigger key."; + } + elsif ($fnret->value && $fnret->value->{'family'} eq 'DSA') { + $fnret->{'msg'} = "Wait, DSA key ? Seriously ? Hello, 90's are over ! Please re-generate a bigger key."; + } + elsif ($fnret->err eq 'KO_FORBIDDEN_ALGORITHM') { + $fnret->{'msg'} = "This key generation algorithm has been disabled on this bastion, please use another one."; + } + elsif (not $fnret) { + $fnret->{'msg'} = "Unknown error (" . $fnret->msg . "), please report to your sysadmin."; + } + else { + if (not grep { $fnret->value->{'family'} eq $_ } qw{ RSA ECDSA ED25519 }) { + $fnret->{'err'} = 'ERR_UNKNOWN_TYPE'; + $fnret->{'msg'} = "Unknown family type (" . $fnret->value->{'family'} . "), please report to your sysadmin."; + } + elsif (not $fnret->value->{'base64'}) { + $fnret->{'err'} = 'ERR_NOT_DECODED'; + $fnret->{'msg'} = "Unknown error parsing your key, please report to your sysadmin."; + } + else { + # ok :) + } + } + + return $fnret; +} + +sub get_from_for_user_key { + my %params = @_; + my $userProvidedIpList = $params{'userProvidedIpList'} || []; # arrayref + my $key = $params{'key'}; + + my $ingressKeysFrom = OVH::Bastion::config('ingressKeysFrom'); + my $ingressKeysFromAllowOverride = OVH::Bastion::config('ingressKeysFromAllowOverride'); + + if (not $ingressKeysFrom or not $ingressKeysFromAllowOverride) { + return R('ERR_CANNOT_LOAD_CONFIGURATION'); + } + + my @ipList = @{$ingressKeysFrom->value}; + + if ($ingressKeysFromAllowOverride->value and scalar @$userProvidedIpList) { + @ipList = @$userProvidedIpList; + } + + my @ipListVerified = grep { OVH::Bastion::is_valid_ip(ip => $_, allowPrefixes => 1) } @ipList; + + my $from = ''; + if (@ipListVerified) { + $from = sprintf('from="%s"', join(',', @ipListVerified)); + } + + # if we have a $key, modify it accordingly + if ($key) { + $key->{'prefix'} = $from; + $key->{'line'} = ($from ? $from . " " : "") . $key->{'typecode'} . " " . $key->{'base64'}; + $key->{'line'} .= " " . $key->{'comment'} if $key->{'comment'}; + $key->{'fromList'} = \@ipListVerified; + } + + return R('OK', value => {from => $from, ipList => \@ipListVerified, key => $key}); +} + +sub generate_ssh_key { + my %params = @_; + my $uid = $params{'uid'}; # optional, uid to chmod key to, only if i'm root + my $gid = $params{'gid'}; # optional, gid to chmod key to, only if i'm root + my $folder = $params{'folder'}; # required, folder to put key into + my $prefix = $params{'prefix'}; # required, prefix of the key name + my $name = $params{'name'}; # optional, in key comment + my $algo = $params{'algo'}; # required, -t ssh-keygen param + my $size = $params{'size'}; # required, -b ssh-keygen param + my $passphrase = $params{'passphrase'}; # optional, passphrase to encrypt key with + my $group_readable = $params{'group_readable'}; # optional, need g+r on privkey + my $fnret; + + if (my @missingParameters = grep { not defined $params{$_} } qw{ folder prefix algo size }) { + local $" = ', '; + return R('ERR_MISSING_PARAMETER', msg => "Missing params @missingParameters on generate_ssh_key() call"); + } + + if (!-d $folder) { + return R('ERR_DIRECTORY_NOT_FOUND', msg => "Specified directory not found ($folder)"); + } + + if (!-w $folder) { + return R('ERR_DIRECTORY_NOT_WRITABLE', msg => "Specified directory can't be written to ($folder)"); + } + + if ($prefix !~ /^[A-Za-z0-9_.-]{1,64}$/) { + return R('ERR_INVALID_PARAMETER', msg => "Specified prefix is invalid ($prefix)"); + } + + if ((defined $uid or defined $gid) and $< != 0) { + return R('ERR_INVALID_PARAMETER', msg => "Can't specify uid or gid when not root"); + } + + $fnret = OVH::Bastion::is_allowed_algo_and_size(algo => $algo, size => $size, way => 'egress'); + $fnret or return $fnret; + + # Forge key + $passphrase = '' if not $passphrase; + $size = '' if $algo eq 'ed25519'; + $name ||= $prefix; + my $sshKeyName = $folder . '/id_' . $algo . $size . '_' . $prefix . '.' . time(); + + if (-e $sshKeyName) { + HEXIT('ERR_KEY_ALREADY_EXISTS', msg => "Can't forge key, generated name already exists"); + } + + my $bastionName = OVH::Bastion::config('bastionName'); + if (!$bastionName) { + return R('ERR_CANNOT_LOAD_CONFIGURATION'); + } + $bastionName = $bastionName->value; + + my @command = ('ssh-keygen'); + push @command, '-t', $algo; + push @command, '-b', $size if $size; + push @command, '-N', $passphrase; + push @command, '-f', $sshKeyName; + push @command, '-C', "$name\@$bastionName:" . time(); + + $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1); + $fnret->err eq 'OK' or return R('ERR_SSH_KEYGEN_FAILED', msg => "Error while generating group key (" . $fnret->msg . ")"); + + my %files = ( + $sshKeyName => ($group_readable ? 0440 : 0400), + $sshKeyName . '.pub' => 0444, + ); + while (my ($file, $chmod) = each(%files)) { + if (not -e $file) { + return R('ERR_SSH_KEYGEN_FAILED', msg => "Couldn't find generated key ($file)"); + } + chown $uid, -1, $file if defined $uid; + chown -1, $gid, $file if defined $gid; + chmod $chmod, $file; + } + return R('OK', value => {file => $sshKeyName}); +} + +# return the list of bastion's ips +sub get_bastion_ips { + my %params = @_; + my $fnret; + + my $egressKeysFrom = OVH::Bastion::config('egressKeysFrom'); + if (!$egressKeysFrom) { + return R('ERR_CANNOT_LOAD_CONFIGURATION'); + } + $egressKeysFrom = $egressKeysFrom->value; + + my @ips; + if (not $egressKeysFrom or @$egressKeysFrom == 0) { + $fnret = OVH::Bastion::execute(cmd => ['hostname', '--all-ip-addresses']); + $fnret or return R('ERR_HOSTNAME_FAILED', msg => "Couldn't determine bastion IP addresses, please fix the config"); + @ips = split(/\s+/, join(' ', @{$fnret->value->{'stdout'} || []})); + } + else { + @ips = @$egressKeysFrom; + } + + my @checkedIps = grep { OVH::Bastion::is_valid_ip(ip => $_, allowPrefixes => 1) } @ips; + + return R('OK', value => \@checkedIps); +} + +my $_cache_get_supported_ssh_algorithms_list_runtime = undef; + +sub get_supported_ssh_algorithms_list { + my %params = @_; + my $way = $params{'way'}; # ingress or egress + + if (not $way) { + return R('ERR_MISSING_PARAMETER', msg => 'Missing required argument way in get_supported_ssh_algorithms_list'); + } + if ($way ne 'ingress' && $way ne 'egress') { + return R('ERR_INVALID_PARAMETER', msg => 'Expected way argument of ingress or egress in get_supported_ssh_algorithms_list'); + } + $way = ucfirst($way); + + # first, filter by config + my $fnret = OVH::Bastion::config("allowed${way}SshAlgorithms"); + $fnret or return $fnret; + my @allowedList = @{$fnret->value}; + + # other vary, detect this by running openssh client -V + my @supportedList; + if (ref $_cache_get_supported_ssh_algorithms_list_runtime eq 'ARRAY') { + @supportedList = @{$_cache_get_supported_ssh_algorithms_list_runtime}; + } + else { + push @supportedList, 'rsa'; # rsa is always supported + $fnret = OVH::Bastion::execute(cmd => [qw{ ssh -V }]); + if ($fnret) { + foreach (@{$fnret->value->{'stdout'} || []}, @{$fnret->value->{'stderr'} || []}) { + if (/OpenSSH_(\d+\.\d+)/) { + my $version = $1; + push @supportedList, 'ecdsa' if ($version gt "5.7"); + push @supportedList, 'ed25519' if ($version gt "6.5"); + $_cache_get_supported_ssh_algorithms_list_runtime = \@supportedList; + last; + } + } + } + } + + # then, take the union of both + my @list; + foreach my $algo (@supportedList) { + push @list, $algo if grep { $_ eq $algo } @allowedList; + } + return R('OK', value => \@list); +} + +sub is_allowed_algo_and_size { + my %params = @_; + my $algo = lc($params{'algo'}); + my $size = $params{'size'}; + my $way = $params{'way'}; + my $fnret; + + if (not $algo) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'algo'"); + } + + $fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => $way); + $fnret or return $fnret; + my @supportedList = @{$fnret->value}; + + if (not grep { $algo eq $_ } @supportedList) { + return R('KO_NOT_SUPPORTED', msg => "The algorithm '$algo' is not supported (or disabled) for $way on this bastion"); + } + + if ($algo eq 'rsa') { + $way = ucfirst($way); + $fnret = OVH::Bastion::config("minimum${way}RsaKeySize"); + $fnret or return $fnret; + if ($size < $fnret->value) { + return R('KO_KEY_SIZE_TOO_SMALL', msg => "For the selected algorithm, minimum configured key size for $way by policy is " . $fnret->value . " bits"); + } + } + elsif ($algo eq 'ecdsa') { + if (not grep { $size eq $_ } qw{ 256 384 521 }) { + return R('KO_KEY_SIZE_INVALID', msg => "For the selected algorithm, valid key sizes are 256, 384, 521"); + } + } + elsif ($algo eq 'ed25519' and $size and $size ne 256) { + return R('KO_KEY_SIZE_INVALID', msg => "For the selected algorithm, key size must be 256"); + } + return R('OK'); +} + +sub is_valid_fingerprint { + my %params = @_; + my $fingerprint = $params{'fingerprint'}; + + if (not $fingerprint) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'fingerprint'"); + } + elsif ($fingerprint =~ /^(([0-9a-f]{2}:){15}[0-9a-f]{2}$)/i) { + return R('OK', value => {type => 'md5', fingerprint => lc($1)}); + } + elsif ($fingerprint =~ /^(SHA256:[\-\/a-z0-9+=]{43})$/i) { + return R('OK', value => {type => 'sha256', fingerprint => $1}); + } + return R('ERR_INVALID_PARAMETER', + msg => "Specified fingerprint is invalid, expected a key fingerprint of the form 12:34:56:78:9a:bc:de:f0:12:34:56:78:9a:bc:de:f0 or SHA256:base64fingerprint"); +} + +sub print_public_key { + my %params = @_; + my $key = $params{'key'}; + my $id = $params{'id'}; + my $err = $params{'err'} || 'OK'; + + require Term::ANSIColor; + + my $line = $key->{'line'}; + if ($key->{'base64'}) { + $line = sprintf("%s%s %s %s", $key->{'prefix'} ? $key->{'prefix'} . ' ' : '', $key->{'typecode'}, $key->{'base64'}, $key->{'comment'}); + } + + if ($key->{'info'}) { + osh_info(Term::ANSIColor::colored("info: " . $key->{'info'}, 'cyan')); + } + if ($key->{'isPiv'}) { + osh_info( + Term::ANSIColor::colored( + "PIV: " + . "TouchPolicy=" + . $key->{'pivInfo'}{'Yubikey'}{'TouchPolicy'} + . ", PinPolicy=" + . $key->{'pivInfo'}{'Yubikey'}{'PinPolicy'} + . ", SerialNo=" + . $key->{'pivInfo'}{'Yubikey'}{'SerialNumber'} + . ", Firmware=" + . $key->{'pivInfo'}{'Yubikey'}{'FirmwareVersion'}, + 'magenta' + ) + ); + } + osh_info( + sprintf( + "%s%s (%s-%d) [%s]%s", + Term::ANSIColor::colored('fingerprint: ', 'green'), + $key->{'fingerprint'} || 'INVALID_FINGERPRINT', + $key->{'family'} || 'INVALID_FAMILY', + $key->{'size'}, + defined $id ? "ID = $id" : POSIX::strftime("%Y/%m/%d", localtime($key->{'mtime'})), + $err eq 'OK' ? '' : ' ***<<' . $err . '>>***', + ) + ); + osh_info(Term::ANSIColor::colored('keyline', 'red') . ' follows, please copy the *whole* line:'); + print($line. "\n"); + osh_info(' '); +} + +sub account_ssh_config_get { + my %params = @_; + my $account = $params{'account'}; + + my $fnret; + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $dir = $fnret->value->{'dir'}; + + # read file content. If it doesn't exist, not a problem + my $sshconfig_data; + if (open(my $sshconfig_fd, '<', "$dir/.ssh/config")) { + local $/ = undef; + $sshconfig_data = <$sshconfig_fd>; + close($sshconfig_fd); + + # ensure we don't have any Host or Match directive. + # If we do, bail out: the file has been modified manually by someone + if ($sshconfig_data =~ /^\s*(Host|Match)\s/mi) { + return R('ERR_FILE_LOCALLY_MODIFIED', + msg => "The ssh configuration of this account has been modified manually. As we can't guarantee modifying it won't cause adverse effects, modification aborted."); + } + + # remove empty lines & comments + my @lines = grep { /./ && !/^\s*#/ } split("\n", $sshconfig_data); + + # lowercase all keys + my %keys = map { m/^(\S+)\s+(.+)$/ ? (lc($1) => $2) : () } @lines; + + return R('OK_EMPTY') if !%keys; + return R('OK', value => \%keys); + } + + return R($! =~ /permission|denied/i ? 'ERR_ACCESS_DENIED' : 'OK_EMPTY'); +} + +sub account_ssh_config_set { + my %params = @_; + my $account = $params{'account'}; + my $key = $params{'key'}; + my $value = $params{'value'}; # if undef, remove $key + + my $fnret; + + if (not defined $key) { + return R('ERR_MISSING_PARAMETER', value => "Expected 'key' parameter"); + } + $key = lc($key); + + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $dir = $fnret->value->{'dir'}; + + # read file content + $fnret = OVH::Bastion::account_ssh_config_get(account => $account); + $fnret or return $fnret; + my %keys = %{$fnret->value()}; + + # remove key if it already exists + delete $keys{$key}; + + # add new key+value + $keys{$key} = $value if defined $value; + + # write modified file. to avoid symlink attacks, remove it then reopen it with sysopen() + unlink("$dir/.ssh/config"); + if (sysopen(my $sshconfig_fd, "$dir/.ssh/config", O_RDWR | O_CREAT | O_EXCL)) { + foreach my $keyWrite (sort keys %keys) { + print $sshconfig_fd $keyWrite . " " . $keys{$keyWrite} . "\n"; + } + close($sshconfig_fd); + } + else { + return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open ssh config file for write: $!"); + } + + # ensure file is readable by everyone (and mainly the account itself) + if (!chmod 0644, "$dir/.ssh/config") { + return R('ERR_CANNOT_CHMOD', msg => "Couldn't ensure the ssh config file perms are correct"); + } + + return R('OK'); +} + +# action=enable: will comment all non-PIV keys from the account's authorized_keys2, +# by calling get_authorized_keys_from_file() with the includePivDisabled param at 1, +# so we also get the commented PIV keys if we already have some, then we comment all +# non-PIV keys, and put them back with put_authorized_keys_to_file() +# +# action=disable: will uncomment all non-PIV keys from the account's authorized_keys2, +# by calling get_authorized_keys_from_file() with the includePivDisabled param at 1, +# so we also get the commented PIV keys, then we uncomment them all and put them back +# with put_authorized_keys_to_file() +sub ssh_ingress_keys_piv_apply { + my %params = @_; + my $account = $params{'account'}; + my $action = $params{'action'}; + + my $fnret; + + if (not $action) { + return R('ERR_MISSING_PARAMETER', msg => "Argument 'action' is required"); + } + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); + $fnret or return $fnret; + + $account = $fnret->value->{'account'}; + my $dir = $fnret->value->{'dir'}; + + $fnret = OVH::Bastion::get_authorized_keys_from_file(account => $account, file => "$dir/.ssh/authorized_keys2", includePivDisabled => 1); + $fnret or return $fnret; + + my $keys = $fnret->value(); + + my @keysToWrite; + if ($action eq 'disable') { + + # uncomment all commented PIV keys + foreach my $key (@$keys) { + if ($key->{'line'} =~ /^# NOTPIV (.+)/) { + $key->{'line'} = $1; + } + push @keysToWrite, $key; + } + } + elsif ($action eq 'enable') { + + # comment all non-PIV and non-verified PIV keys + foreach my $key (@$keys) { + my $line = $key->{'line'}; + + # remove any commented PIV marker for the verify_piv() + $line =~ s/^# NOTPIV //; + if ($key->{'pivAttestationCertificate'} && $key->{'pivKeyCertificate'}) { + $fnret = OVH::Bastion::verify_piv(key => $line, attestationCertificate => $key->{'pivAttestationCertificate'}, keyCertificate => $key->{'pivKeyCertificate'}); + if (!$fnret) { + + # PIV verify failed prepend with # NOTPIV + $key->{'line'} = "# NOTPIV " . $line; + } + } + else { + # no certificates => not PIV, prepend with # NOTPIV + $key->{'line'} = "# NOTPIV " . $line; + } + push @keysToWrite, $key; + } + } + else { + return R('ERR_INVALID_PARAMETER', msg => "Argument 'action' must be either 'enable' or 'disable'"); + } + + $fnret = OVH::Bastion::put_authorized_keys_to_file(account => $account, file => "$dir/.ssh/authorized_keys2", data => \@keysToWrite); + $fnret or return $fnret; + + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'account', + fields => [['action', 'ingress-piv-apply'], ['account', $account], ['policy', $action], ['comment', 'auto-reaper'],] + ); + + return R('OK'); +} + +1; diff --git a/lib/perl/OVH/Result.pm b/lib/perl/OVH/Result.pm new file mode 100644 index 0000000..ad1ca51 --- /dev/null +++ b/lib/perl/OVH/Result.pm @@ -0,0 +1,77 @@ +package OVH::Result; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +# not enabled on prod, see "trace" comment below +# use Carp (); +# $Carp::MaxArgLen = 512; +# $Carp::MaxArgNums = 32; + +use parent qw(Exporter); +our @EXPORT = qw{ R }; ## no critic (AutomaticExportation) + +use overload ( + 'bool' => \&is_ok, + '""' => \&msg, +); + +sub new { ## no critic (ArgUnpacking) + my $type = shift; + my %params = @_; + my $err = $params{'err'}; + my $value = $params{'value'}; + my $msg = $params{'msg'}; + my $silent = $params{'silent'}; + + my $Object = { + err => $err, + value => $value, + msg => $msg, + + # uncomment this and 'use Carp' above to trace results, + # slows down code and gets noticeable on very busy bastions + # trace => Carp::longmess("new Result"), + }; + + bless $Object, 'OVH::Result'; + + # uncomment this and 'use Carp' above to print on STDERR any non-OK result + # that is generated by any function, helpful to debug complex new features + # print STDERR Carp::longmess("$0 R[" . ($err ? $err : '') . " " . ($value ? $value : '') . " " . ($msg ? $msg : '')) if (!$silent && !$Object->is_ok()); + + return $Object; +} + +sub R { return OVH::Result->new(err => shift, @_); } ## no critic (ArgUnpacking) + +=cut uncomment for result tracing +sub R { + my ($package, $filename, $line) = caller(0); + my (undef,undef,undef,$sub) = caller(1); + my $err = shift; + my %params = @_; + print "R[err=$err msg=".$params{'msg'}."] sub=$sub in $filename:$line\n"; + return OVH::Result->new(err => $err, %params); +} +=cut + +sub err { return shift->{'err'} } +sub value { return shift->{'value'} } +sub msg { return $_[0]->{'msg'} ? $_[0]->{'msg'} : $_[0]->{'err'} } ## no critic (ArgUnpacking) + +sub is_err { return shift->{'err'} =~ /^ERR/ } +sub is_ok { return shift->{'err'} =~ /^OK/ } +sub is_ko { return shift->{'err'} =~ /^KO/ } + +sub TO_JSON { + my $self = shift; + return { + error_code => $self->err, + value => $self->value, + error_message => $self->msg, + } if (ref $self eq 'OVH::Result'); + return {}; +} + +1; diff --git a/lib/perl/OVH/SimpleLog.pm b/lib/perl/OVH/SimpleLog.pm new file mode 100644 index 0000000..6c6058e --- /dev/null +++ b/lib/perl/OVH/SimpleLog.pm @@ -0,0 +1,123 @@ +package OVH::SimpleLog; + +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; + +# Simple package to log either to a file, to syslog, or to both +# Exports the _log, _warn and _err routines to do so + +use base qw (Exporter); +our @EXPORT = qw(_log _warn _err); ## no critic (ProhibitAutomaticExportation) + +use Term::ANSIColor; +use Sys::Syslog qw{}; + +# Log file handler +my $LOG_FH; + +# Syslog +my $FACILITY; + +# Program name +my $PROGNAME; + +BEGIN { + # Extract program base name + $PROGNAME = $0; + if ($PROGNAME =~ /\/([^\/]+)$/) { + $PROGNAME = $1; + } +} + +# Set a log file +sub setLogFile { + my $filename = shift; + if (not open($LOG_FH, '>>', $filename)) { + _warn("Unable to open log file '$filename' ($!)"); + return 0; + } + return 1; +} + +sub setSyslog { + + # if we previously opened syslog, close it + closeSyslog(); + + # then (re)open it with the wanted facility + $FACILITY = shift; + Sys::Syslog::openlog($PROGNAME . "[$$]", 'nofatal', $FACILITY); + + return 1; +} + +sub closeSyslog { + + Sys::Syslog::closelog() if $FACILITY; + undef $FACILITY; + + return 1; +} + +sub _log { _display('LOG', @_); return 1; } ## no critic (RequireArgUnpacking,ProhibitUnusedPrivateSubroutines) +sub _warn { _display('WARN', @_); return 1; } ## no critic (RequireArgUnpacking,ProhibitUnusedPrivateSubroutines) +sub _err { _display('ERR', @_); return 1; } ## no critic (RequireArgUnpacking,ProhibitUnusedPrivateSubroutines) + +# Display a message +sub _display { + my $level = shift; + my $message = shift; + + # Prepare message and possibly color + my $color = ''; + my $fullmsg = $message; + my $OUT = 'STDOUT'; + if ($level eq 'ERR') { + $color = 'red'; + $fullmsg = "ERROR: $message"; + $OUT = 'STDERR'; + } + elsif ($level eq 'WARN') { + $color = 'yellow'; + $fullmsg = "WARN: $message"; + $OUT = 'STDERR'; + } + + # If it's not for a terminal, don't colorize log + # perlcritic doesn't like -t, but IO::Interactive is not in core as per corelist + $color = '' if not -t $OUT; ## no critic (ProhibitInteractiveTest) + + my $coloredmsg = $fullmsg; + $coloredmsg = colored($fullmsg, $color) if $color; + if ($OUT eq 'STDERR') { + print STDERR $coloredmsg . "\n"; + } + else { + print $coloredmsg. "\n"; + } + + # Print on a log file (if needed) + if ($LOG_FH) { + printf $LOG_FH "%s [%6s] %s: %s\n", scalar(localtime()), $level, $PROGNAME, $message; + } + + # Push to syslog (only if a facility has been defined, which means openlog() has been called) + if ($FACILITY) { + + $level = lc($level); + $level = 'info' if (!grep { $level eq $_ } qw{ warn err }); + eval { Sys::Syslog::syslog($level, $fullmsg); }; + if ($@) { + osh_warn("Couldn't syslog, report to administrator ($@)"); + } + } + + return 1; +} + +END { + close($LOG_FH) if (defined $LOG_FH); + Sys::Syslog::closelog() if (defined $FACILITY); +} + +1; diff --git a/lib/shell/colors.inc b/lib/shell/colors.inc new file mode 100644 index 0000000..600a314 --- /dev/null +++ b/lib/shell/colors.inc @@ -0,0 +1,17 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2034 + +RED=$(printf "%b" '\033[31m') +GREEN=$(printf "%b" '\033[32m') +YELLOW=$(printf "%b" '\033[33m') +BLUE=$(printf "%b" '\033[34m') + +BOLD_CYAN=$(printf "%b" '\033[1;36m') + +WHITE_ON_RED=$(printf "%b" '\033[41m') +WHITE_ON_BLUE=$(printf "%b" '\033[44m') +BLACK_ON_GREEN=$(printf "%b" '\033[30m\033[42m') +BLACK_ON_RED=$(printf "%b" '\033[1;30m\033[41m') + +NOC=$(printf "%b" '\033[0m') diff --git a/lib/shell/functions.inc b/lib/shell/functions.inc new file mode 100644 index 0000000..dc392be --- /dev/null +++ b/lib/shell/functions.inc @@ -0,0 +1,288 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash + +# shellcheck source=lib/shell/colors.inc disable=SC2128 +. "$(dirname "$BASH_SOURCE")"/colors.inc + +OS_FAMILY=$(uname -s) +LINUX_DISTRO=unknown +DISTRO_VERSION='' +DISTRO_LIKE='' +if [ -e /etc/os-release ]; then + LINUX_DISTRO=$(grep '^ID=' /etc/os-release | cut -d= -f2 | tr -d '"') + DISTRO_LIKE=$(grep '^ID_LIKE=' /etc/os-release | cut -d= -f2 | tr -d '"') + DISTRO_VERSION=$(grep '^VERSION_ID=' /etc/os-release | cut -d= -f2 | tr -d '"') +fi +if [ -z "$LINUX_DISTRO" ] || [ -z "$DISTRO_VERSION" ]; then + if command -v lsb_release >/dev/null 2>&1; then + LINUX_DISTRO=$(lsb_release -si) + DISTRO_VERSION=$(lsb_release -sr) + elif [ -e /etc/debian_version ]; then + LINUX_DISTRO=debian + DISTRO_VERSION=$(cat /etc/debian_version) + elif [ -e /etc/redhat-release ]; then + LINUX_DISTRO=redhat + fi +fi +if [ "$DISTRO_VERSION" = "buster/sid" ]; then + DISTRO_VERSION=10 +fi +LINUX_DISTRO=$(echo "$LINUX_DISTRO" | tr '[:upper:]' '[:lower:]' | tr -d ' ') +# shellcheck disable=SC2034 +DISTRO_VERSION_MAJOR=$(echo "$DISTRO_VERSION" | grep -Eo '^[0-9]+' || true) +[ -z "$DISTRO_LIKE" ] && DISTRO_LIKE="$LINUX_DISTRO" + +# shellcheck disable=SC2034 +LOCKFILE=/var/run/bastion-upgrade.lock + +# set ETC_DIR +ETC_DIR=/etc +[ "$OS_FAMILY" = FreeBSD ] && ETC_DIR=/usr/local/etc +[ "$OS_FAMILY" = NetBSD ] && ETC_DIR=/usr/pkg/etc +# shellcheck disable=SC2034 +BASTION_ETC_DIR=$ETC_DIR/bastion + +# set UID0 and GID0 (os-dependent) +# shellcheck disable=SC2034 +UID0=$(getent passwd 0 | awk -F: '{print $1}') +# shellcheck disable=SC2034 +GID0=$(getent group 0 | awk -F: '{print $1}') +# shellcheck disable=SC2034 +UID0HOME=$(getent passwd 0 | awk -F: '{print $6}') + +# set sudoers_dir +SUDOERS_FILE=/etc +if [ -e /usr/local/etc/sudoers ] && ! [ -e /etc/sudoers ] ; then + SUDOERS_FILE=/usr/local/etc +elif [ -e /usr/pkg/etc/sudoers ] && ! [ -e /etc/sudoers ] ; then + SUDOERS_FILE=/usr/pkg/etc +fi +# shellcheck disable=SC2034 +SUDOERS_DIR="$SUDOERS_FILE/sudoers.d" +SUDOERS_FILE="$SUDOERS_FILE/sudoers" + +# set SSH_DIR +SSH_DIR=$ETC_DIR/ssh +if [ ! -e "$SSH_DIR" ]; then + SSH_DIR=/etc/ssh +fi + +action_doing() +{ + printf '\r*** %b\n' "$*" +} +action_detail() +{ + printf '\r`-> %b\n' "$*" +} +action_done() +{ + printf "%b" "\\r\`-> [${GREEN} OK ${NOC}]" + if [ -n "$1" ]; then + printf "%b" " ... $*" + fi + echo +} +action_warn() +{ + printf '%b %b\n' "\\r\`-> [${YELLOW}WARN${NOC}]" "$*" +} +action_error() +{ + printf '%b %b\n' "\\r\`-> [${RED}ERR.${NOC}]" "$*" +} +action_na() +{ + printf '%b %b\n' "\\r\`-> [${BLUE}N/A.${NOC}]" "$*" +} + +sed_compat() +{ + local _sedcmd="$1" _file="$2" + if sed --version 2>/dev/null | grep -q GNU || [ "$OS_FAMILY" = NetBSD ] ; then + # GNU sed or NetBSD + sed -i -re "$_sedcmd" "$_file" + else + # other BSD-style sed + sed -i '' -re "$_sedcmd" "$_file" + fi +} + +useradd_compat() +{ + local _user="$1" _uid="" _home="" _shell="" _gid="" _extra="" + shift + if [ -n "$*" ]; then _uid="$1"; shift; fi + if [ -n "$*" ]; then _home="$1"; shift; fi + if [ -n "$*" ]; then _shell="$1"; shift; fi + if [ -n "$*" ]; then _gid="$1"; shift; fi + + [ -n "$_uid" ] && _extra="$_extra -u $_uid" + if [ -n "$_home" ]; then + _extra="$_extra -d $_home" + fi + if [ "$_home" != "/nonexistent" ]; then + _extra="$_extra -m" + fi + if [ -n "$_gid" ]; then + _extra="$_extra -g $_gid" + elif command -v useradd >/dev/null ; then + [ "$OS_FAMILY" != OpenBSD ] && [ "$OS_FAMILY" != NetBSD ] && _extra="$_extra -U" + fi + + # special case for /bin/false, it might be /usr/bin/false on some BSDs + if [ "$_shell" = /bin/false ] && ! [ -e /bin/false ] && [ -e /usr/bin/false ] ; then + _shell="/usr/bin/false" + fi + [ -n "$_shell" ] && _extra="$_extra -s $_shell" + + if command -v useradd >/dev/null ; then + if [ "$OS_FAMILY" = OpenBSD ] || [ "$OS_FAMILY" = NetBSD ]; then + # shellcheck disable=SC2086 + useradd -r 400..499 -g =uid $_extra "$_user" + else + # shellcheck disable=SC2086 + useradd -r -p '*' $_extra "$_user" + fi + elif command -v pw >/dev/null ; then + # shellcheck disable=SC2086 + pw useradd -n "$_user" $_extra + else + echo "useradd_compat: Don't know how to add user $_user!" >&2 + return 1 + fi +} + +groupadd_compat() +{ + local _group="$1" _gid="$2" + if command -v groupadd >/dev/null ; then + if [ -n "$_gid" ] && [ "$_gid" != HIGH ]; then + # works for Linux, NetBSD and OpenBSD + groupadd -g "$_gid" "$_group" + elif [ "$OS_FAMILY" = Linux ] ; then + if [ "$_gid" = HIGH ]; then + groupadd "$_group" + else + groupadd -r "$_group" + fi + elif [ "$OS_FAMILY" = NetBSD ]; then + if [ "$_gid" = HIGH ]; then + groupadd "$_group" + else + groupadd -r 300..399 "$_group" + fi + elif [ "$OS_FAMILY" = OpenBSD ]; then + if [ -z "$_gid" ] ; then + # try to pick a groupid ourselves... + local _g=0 + for _g in {300..399} ; do + if ! groupinfo -e "$_g" ; then + groupadd -g "$_g" "$_group" + return 0 + fi + done + fi + # couldn't find any (or highid asked)... let the system decide + groupadd "$_group" + else + groupadd "$_group" + fi + elif command -v pw >/dev/null ; then + if [ -n "$_gid" ] && [ "$_gid" != HIGH ]; then + pw groupadd -n "$_group" -g "$_gid" + return 0 + elif [ -z "$_gid" ] ; then + # try to pick a groupid ourselves... + local _g=0 + for _g in {300..399} ; do + if ! pw groupshow -g "$_g" &>/dev/null ; then + pw groupadd -g "$_g" -n "$_group" + return 0 + fi + done + fi + # couldn't find any (or highid asked)... let the system decide + pw groupadd -n "$_group" + else + echo "groupadd_compat: Don't know how to add group $_group!" >&2 + return 1 + fi +} + +usermod_changeuid_compat() +{ + local _user="$1" _newuid="$2" + if command -v usermod >/dev/null ; then + usermod -u "$_newuid" "$_user" + elif command -v pw >/dev/null ; then + pw usermod -n "$_user" -u "$_newuid" + else + echo "usermod_changeuid_compat: Don't know how to change uid of user $_user to $_newuid!" >&2 + return 1 + fi +} + +group_change_gid_compat() +{ + local _group="$1" _newgid="$2" + if command -v groupmod >/dev/null ; then + groupmod -g "$_newgid" "$_group"; return $? + elif command -v pw >/dev/null ; then + pw groupmod -n "$_group" -g "$_newgid"; return $? + else + echo "group_change_gid_compat: Don't know how to change gid of group $_group to $_newgid!" >&2 + return 1 + fi +} + +group_rename_compat() +{ + local _oldname="$1" _newname="$2" + if command -v groupmod >/dev/null ; then + groupmod -n "$_newname" "$_oldname"; return $? + elif command -v pw >/dev/null ; then + pw groupmod -n "$_oldname" -l "$_newname"; return $? + else + echo "group_rename_compat: Don't know how to rename group $_oldname to $_newname!" >&2 + return 1 + fi +} + +add_user_to_group_compat() +{ + local _user="$1" _group="$2" + if command -v usermod >/dev/null ; then + if [ "$OS_FAMILY" = OpenBSD ] || [ "$OS_FAMILY" = NetBSD ] ; then + usermod -G "$_group" "$_user" + else + usermod -a -G "$_group" "$_user" + fi + elif command -v pw >/dev/null ; then + pw groupmod -n "$_group" -m "$_user" + else + echo "add_user_to_group_compat: don't know how to add $_user to $_group!" >&2 + return 1 + fi +} + +_logtag="$(basename "$0")[$$]" +__log() +{ + level="$1" + shift + [ -n "$LOG_FACILITY" ] && logger -t "$_logtag" -p "$LOG_FACILITY"."$level" "$*" + [ -z "$LOG_QUIET" ] && echo "$(date +'[%Y/%m/%d %H:%M:%S] ')$*" +} +_log() +{ + __log info "$*" +} +_warn() +{ + __log warn "WARN: $*" +} +_err() +{ + __log err "ERROR: $*" >&2 +} diff --git a/tests/functional/docker/docker_build_and_run_tests.sh b/tests/functional/docker/docker_build_and_run_tests.sh new file mode 100755 index 0000000..09d0731 --- /dev/null +++ b/tests/functional/docker/docker_build_and_run_tests.sh @@ -0,0 +1,166 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../../..) +# shellcheck source=lib/shell/colors.inc +. "$basedir"/lib/shell/colors.inc + +namespace=the-bastion-test + +target="$1" +test_script="$2" + +if [ -z "$target" ] || [ "$target" = "--list-targets" ]; then + targets=$(grep -l '^# TESTENV' "$(dirname "$0")"/../../../docker/Dockerfile.* | sed -re 's=^.+/Dockerfile\.==') + if [ -z "$target" ]; then + echo "Usage: $0 " >&2 + echo -n "Supported targets are: " >&2 + grep -l '^# TESTENV' "$(dirname "$0")"/../../../docker/Dockerfile.* | sed -re 's=^.+/Dockerfile\.==' | tr '\n' " " >&2 + echo >&2 + exit 1 + else + # shellcheck disable=SC2086 + echo $targets + exit 0 + fi +fi + +target_dockerfile="$(dirname "$0")"/../../../docker/Dockerfile."$target" +if [ ! -f "$target_dockerfile" ] ; then + echo "Couldn't find a Dockerfile for $target ($target_dockerfile)" >&2 + exit 1 +fi + +# build test env +echo "Building test environment" +testenv_dockerfile="$(dirname "$0")/../../../docker/Dockerfile.tester" +docker build -f "$testenv_dockerfile" -t "$namespace:tester" "$(dirname "$0")"/../../.. + +# build target +echo "Building target environment" +docker build -f "$target_dockerfile" -t "$namespace:$target" --build-arg "TEST_QUICK=$TEST_QUICK" "$(dirname "$0")"/../../.. + +# create temp key +echo "Create user and root SSH keys" +privdir=$(mktemp -d) +trap 'rm -rf "$privdir"' EXIT +ssh-keygen -t ecdsa -N '' -q -f "$privdir"/userkey +USER_PRIVKEY_B64=$(base64 -w0 < "$privdir"/userkey) +USER_PUBKEY_B64=$(base64 -w0 < "$privdir"/userkey.pub) +ssh-keygen -t ecdsa -N '' -q -f "$privdir"/rootkey +ROOT_PRIVKEY_B64=$(base64 -w0 < "$privdir"/rootkey) +ROOT_PUBKEY_B64=$(base64 -w0 < "$privdir"/rootkey.pub) +rm -rf "$privdir" +trap - EXIT + +varstoadd='' +privileged='' +for var in $(grep '^# TESTENV' "$target_dockerfile" | tail -n1 | sed -re 's/^# TESTENV//') +do + echo "$var" | grep -Eq '^[A-Z0-9_]+=[01]$' && varstoadd="$varstoadd -e $var " + [ "$var" = "PRIVILEGED=1" ] && privileged='--privileged' +done + +echo "Configuring network" +docker rm -f "bastion_${target}_target" 2>/dev/null || true +docker rm -f "bastion_${target}_tester" 2>/dev/null || true +if docker inspect "bastion-$target" >/dev/null 2>&1; then + docker network rm "bastion-$target" >/dev/null +fi +docker network create "bastion-$target" >/dev/null + +# run target but force entrypoint to test one, and add some keys in env (will be shared with tester) +echo "Starting target instance" +docker run $privileged \ + --name="bastion_${target}_target" \ + --network "bastion-$target" \ + -d \ + --entrypoint=/opt/bastion/tests/functional/docker/target_role.sh \ + -e USER_PUBKEY_B64="$USER_PUBKEY_B64" \ + -e ROOT_PUBKEY_B64="$ROOT_PUBKEY_B64" \ + -e TARGET_USER=user5000 \ + -e TEST_QUICK="${TEST_QUICK:-0}" \ + $namespace:"$target" +docker logs -f "bastion_${target}_target" | sed -u -e 's/^/target: /;s/$/\r/' & + +show_target_logs() { + if [ "$ret" -ne 0 ] && [ "$ret" -ne 255 ]; then + echo + echo '>>> TARGET LOGS FOLLOW <<<' + docker logs "bastion_${target}_target" | sed -u -e 's/^/target: /;s/$/\r/' + fi +} + +cleanup() { + set +e + docker rm -f "bastion_${target}_target" "bastion_${target}_tester" >/dev/null 2>/dev/null || true + docker network rm "bastion-$target" >/dev/null +} + +cleanup_exit() { + show_target_logs + cleanup +} + +cleanup_int() { + printf "%b%b%b\\n" "$WHITE_ON_RED" '>>> CLEANING UP, DO NOT CTRL+C AGAIN! <<<' "$NOC" + cleanup +} + +trap "cleanup_int" INT HUP + +# run test env on it +if [[ -t 1 ]] && [ -z "$DOCKER_TTY" ]; then + DOCKER_TTY="true" +else + DOCKER_TTY="false" +fi +echo "Starting test instance and run tests with --tty=$DOCKER_TTY" +set +e +# shellcheck disable=SC2086 +docker run \ + --name="bastion_${target}_tester" \ + --network "bastion-$target" \ + -i \ + --tty=$DOCKER_TTY \ + -e TARGET_IP="bastion_${target}_target" \ + -e TARGET_PORT=22 \ + -e TARGET_USER=user5000 \ + -e USER_PRIVKEY_B64="$USER_PRIVKEY_B64" \ + -e ROOT_PRIVKEY_B64="$ROOT_PRIVKEY_B64" \ + -e TARGET="$target " \ + -e TEST_SCRIPT="$test_script" \ + -e TEST_QUICK="${TEST_QUICK:-0}" \ + $varstoadd $namespace:tester +ret=$? +if [ $ret -ne 0 ]; then + printf '%b%b%b\n' "$WHITE_ON_RED" "Test instance returned $ret" "$NOC" +fi +trap - INT HUP + +show_target_logs + +if [ $ret -ne 0 ]; then + if [ $ret -eq 255 ]; then + printf '%b%b%b\n' "$WHITE_ON_RED" "=====================================" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" ">>> TARGET DIDN'T START CORRECTLY <<<" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" "=====================================" "$NOC" + docker logs "bastion_${target}_target" + elif [ $ret -eq 254 ]; then + printf '%b%b%b\n' "$WHITE_ON_RED" "============================" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" ">>> PREREQUISITES FAILED <<<" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" "============================" "$NOC" + docker logs "bastion_${target}_tester" + else + printf '%b%b%b\n' "$WHITE_ON_RED" "==============================================" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" ">>> AN OVERVIEW OF THE FAILED TESTS FOLLOW <<<" "$NOC" + printf '%b%b%b\n' "$WHITE_ON_RED" "==============================================" "$NOC" + docker logs "bastion_${target}_tester" | grep -B5 -F -- '[FAIL]' | grep -vF -- '[ OK ]' + echo "=== last few lines of the tester logs follow:" + docker logs "bastion_${target}_tester" | tail -7 + fi +fi + +cleanup +exit $ret diff --git a/tests/functional/docker/docker_build_and_run_tests_all.sh b/tests/functional/docker/docker_build_and_run_tests_all.sh new file mode 100755 index 0000000..05fcf55 --- /dev/null +++ b/tests/functional/docker/docker_build_and_run_tests_all.sh @@ -0,0 +1,54 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: + +basedir=$(readlink -f "$(dirname "$0")"/../../..) +# shellcheck source=lib/shell/colors.inc +. "$basedir"/lib/shell/colors.inc + +cd "$(dirname "$0")" || exit 1 + +targets=$(./docker_build_and_run_tests.sh --list-targets) + +printf '%b%b%b\n' "$WHITE_ON_BLUE" "============================================================" "$NOC" +printf '%b%b%b\n' "$WHITE_ON_BLUE" "Testing all targets in parallel, ensure you have enough RAM!" "$NOC" +printf '%b%b%b\n' "$WHITE_ON_BLUE" "============================================================" "$NOC" +echo "Targets: $targets" + +sleep 5 + +for t in $targets +do + ( + rm -f "/tmp/.$t" + DOCKER_TTY=false ./docker_build_and_run_tests.sh "$t" + echo $? > "/tmp/.$t" + ) & +done +wait + +echo + +nberrors=0 + +for t in $targets +do + err=$(cat "/tmp/.$t" 2>/dev/null) + rm -f "/tmp/.$t" + if [ -z "$err" ]; then + printf "%b%15s: tests couldn't run properly%b\\n" "$BLACK_ON_RED" "$t" "$NOC" + nberrors=$(( nberrors + 1 )) + elif [ "$err" = 0 ]; then + printf "%b%15s: no errors :)%b\\n" "$BLACK_ON_GREEN" "$t" "$NOC" + elif [ "$err" = 143 ]; then + printf "%b%15s: tests interrupted%b\\n" "$BLACK_ON_RED" "$t" "$NOC" + nberrors=$(( nberrors + 1 )) + elif [ "$err" -lt 254 ]; then + printf "%b%15s: $err tests failed%b\\n" "$BLACK_ON_RED" "$t" "$NOC" + nberrors=$(( nberrors + 1 )) + else + printf "%b%15s: $err errors%b\\n" "$BLACK_ON_RED" "$t" "$NOC" + nberrors=$(( nberrors + 1 )) + fi +done + +exit "$nberrors" diff --git a/tests/functional/docker/target_role.sh b/tests/functional/docker/target_role.sh new file mode 100755 index 0000000..1cdf148 --- /dev/null +++ b/tests/functional/docker/target_role.sh @@ -0,0 +1,116 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# This entrypoint is ONLY for instances to run functional tests on +# DO NOT USE IN PROD (check docker/ under main dir for that) +set -e +set -u + +basedir=$(readlink -f "$(dirname "$0")"/../../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +# do we have a key? +[ -n "$USER_PUBKEY_B64" ] && user_pubkey=$(base64 -d <<< "$USER_PUBKEY_B64") +[ -n "$ROOT_PUBKEY_B64" ] && root_pubkey=$(base64 -d <<< "$ROOT_PUBKEY_B64") +if [ -z "$user_pubkey" ] ; then + echo "Missing ENV user_pubkey (or USER_PUBKEY_B64), aborting" >&2 + exit 1 +elif [ -z "$root_pubkey" ] ; then + echo "Missing ENV root_pubkey (or ROOT_PUBKEY_B64), aborting" >&2 + exit 1 +elif [ -z "$TARGET_USER" ]; then + echo "Missing ENV TARGET_USER, aborting" >&2 +fi + +# modify default ssh/sshd configs +tmpf=$(mktemp -t bastion.XXXXXXXX) +grep -Evi '^(stricthostkeychecking) ' /etc/ssh/ssh_config > "$tmpf" || true +cat "$tmpf" > /etc/ssh/ssh_config +grep -Evi '^(port|authorizedkeysfile) ' /etc/ssh/sshd_config > "$tmpf" || true +cat "$tmpf" > /etc/ssh/sshd_config +rm -f "$tmpf" +echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config +echo "Port 22" >> /etc/ssh/sshd_config +echo "Port 226" >> /etc/ssh/sshd_config + +# put the root pubkey on the root account +[ -d "$UID0HOME/.ssh" ] || mkdir "$UID0HOME/.ssh" +echo "$root_pubkey" >> "$UID0HOME/.ssh/authorized_keys" +# also unlock the root account, which can sometimes prevent us connecting through SSH (CentOS 8) +usermod -U "$UID0" + +HOME="$UID0HOME" USER="$UID0" "$basedir"/bin/plugin/restricted/accountCreate '' '' '' '' --uid 5000 --account "$TARGET_USER" --public-key "$user_pubkey FOR_TESTS_ONLY" +HOME="$UID0HOME" USER="$UID0" "$basedir"/bin/plugin/restricted/accountGrantCommand '' '' '' '' --account "$TARGET_USER" --command accountGrantCommand + +# add an account with local shell access (to mimic a remote server) +useradd_compat test-shell_ "" "" /bin/sh +test -d ~test-shell_/.ssh || mkdir ~test-shell_/.ssh +# and copy the bastion pubkey of the bastion account we created +cat /home/"$TARGET_USER"/.ssh/id_*.pub > ~test-shell_/.ssh/authorized_keys +# add it to the bastion-nopam group +add_user_to_group_compat test-shell_ bastion-nopam + +# install a fake ttyrec just so that our connection tests work +if [ ! -e /usr/bin/ttyrec ] ; then + "$basedir"/bin/admin/install --nothing --no-wait --install-fake-ttyrec +fi + +# if we have other specific scripts to run, run them +if [ -d "$basedir/tests/functional/docker/target_role.d/" ]; then + while IFS= read -r -d '' script + do + echo "### running $script" + # shellcheck disable=SC1090 + if ! . "$script"; then + echo "ERROR while running $script, bailing out..." >&2 + exit 1 + fi + done < <(find "$basedir/tests/functional/docker/target_role.d/" -mindepth 1 -maxdepth 1 -type f -name "*.sh" -print0) +fi + +# now OS-specific things + +if [ "$(uname -s)" = Linux ] ; then + + test -x /etc/init.d/ssh && /etc/init.d/ssh start + test -x /etc/init.d/syslog-ng && /etc/init.d/syslog-ng start + + if [ -e /etc/redhat-release ]; then + # centos has systemd and it doesn't work well under docker + # so we have to start the daemon manually :| + /usr/sbin/sshd + /usr/sbin/syslog-ng -F -p /var/run/syslogd.pid & disown + fi + if [ -f /etc/os-release ] && grep -q suse /etc/os-release; then + /usr/sbin/sshd-gen-keys-start + /usr/sbin/sshd + sed -i -re 's/s_src/src/' /etc/syslog-ng/conf.d/20-bastion.conf + /usr/sbin/syslog-ng-service-prepare + /usr/sbin/syslog-ng + fi + +elif [ "$(uname -s)" = OpenBSD ] || [ "$(uname -s)" = FreeBSD ] || [ "$(uname -s)" = NetBSD ] ; then + + # setup some 127.0.0.x IPs (needed for our tests) + # this automatically works under Linux on lo + i=2 + while [ $i -lt 20 ] ; do + ifconfig lo0 127.0.0.$i netmask 255.0.0.0 alias + (( i++ )) + done + ifconfig lo0 127.7.7.7 netmask 255.0.0.0 alias + + set +e + for st in restart onestart + do + test -x /etc/rc.d/sshd && /etc/rc.d/sshd $st + test -x /etc/rc.d/syslog_ng && /etc/rc.d/syslog_ng $st + test -x /usr/local/etc/rc.d/syslog-ng && /usr/local/etc/rc.d/syslog-ng $st + done + set -e +fi + +echo "Now sleeping forever (docker mode)" +while : ; do + sleep 3600 +done diff --git a/tests/functional/docker/tester_role.sh b/tests/functional/docker/tester_role.sh new file mode 100755 index 0000000..88d9a0c --- /dev/null +++ b/tests/functional/docker/tester_role.sh @@ -0,0 +1,60 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +set -e +set -u + +basedir=$(readlink -f "$(dirname "$0")"/../../..) +# shellcheck source=lib/shell/colors.inc +. "$basedir"/lib/shell/colors.inc + + +if [ "$TEST_QUICK" = 0 ]; then + printf '%b>>> %b <<<%b\n' "$BOLD_CYAN" "SHELL CHECK" "$NOC" + "$(dirname "$0")"/../../../bin/dev/shell-check.sh || exit 254 + + printf '%b>>> %b <<<%b\n' "$BOLD_CYAN" "PERL CRITIC" "$NOC" + "$(dirname "$0")"/../../../bin/dev/perl-critic.sh || exit 254 + + printf '%b>>> %b <<<%b\n' "$BOLD_CYAN" "PERL TIDY" "$NOC" + "$(dirname "$0")"/../../../bin/dev/perl-tidy.sh test || exit 254 +fi + +printf '%b>>> %b <<<%b\n' "$BOLD_CYAN" "SETTING UP KEYS" "$NOC" +base64 -d <<< "$USER_PRIVKEY_B64" > /root/user.privkey +chmod 400 /root/user.privkey +base64 -d <<< "$ROOT_PRIVKEY_B64" > /root/root.privkey +chmod 400 /root/root.privkey + +printf '%b>>> %b <<<%b\n' "$BOLD_CYAN" "STARTING TESTS" "$NOC" + +chmod 755 "$(dirname "$0")/../launch_tests_on_instance.sh" +[ ! -d "/root/.ssh" ] && mkdir /root/.ssh + +delay=10 +for i in $(seq 1 $delay); do + echo "tester: waiting for target docker to be up ($i/$delay)..." + fping -r 1 "$TARGET_IP" && break +done +if [ "$i" = "$delay" ]; then + echo "tester: Error, target doesn't answer to pings after $delay tries :(" + exit 255 +fi + +delay=300 +for i in $(seq 1 $delay); do + echo "tester: waiting for target SSH to be up ($i/$delay)..." + sleep 1 + if echo test | nc -w 1 "$TARGET_IP" "$TARGET_PORT" | grep -q ^SSH-2 ; then + echo "tester: it's alive, starting tests!" + [ "$TEST_QUICK" = 1 ] && export nocc=1 + "$(dirname "$0")"/../launch_tests_on_instance.sh "$TARGET_IP" "$TARGET_PORT" "$TARGET_USER" /root/user.privkey /root/root.privkey; ret=$? + [ "$ret" -gt 253 ] && ret=253 + exit "$ret" + elif ! fping -r 1 "$TARGET_IP" >/dev/null 2>&1; then + echo "tester: Error, target stopped pinging before SSH was up, problem in target_role.sh entrypoint?" + exit 255 + fi +done + +echo "tester: Error, target is not alive or not listening for SSH :(" +exit 255 diff --git a/tests/functional/fake_ttyrec.sh b/tests/functional/fake_ttyrec.sh new file mode 100755 index 0000000..38900a5 --- /dev/null +++ b/tests/functional/fake_ttyrec.sh @@ -0,0 +1,13 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck disable=SC2046 +set -- $(getopt -o 'ZcCupVhvanf:z:d:t:T:k:s:e:l:F:' -l "zstd,level:,verbose,append,cheatcodes,no-cheatcodes,shell-cmd:,dir:,output:,uuid:,no-openpty,lock-timeout:,kill-timeout:,msg:,count-bytes,term:,version,help,zstd-try,max-flush-time:,name-format:" -- "$@") +while [ "$1" != "--" ]; do + if [ "$1" = "-V" ]; then + echo "fake-ttyrec v1.1.4.0" + exit 0 + fi + shift +done +shift +eval "$@" diff --git a/tests/functional/launch_tests_on_instance.sh b/tests/functional/launch_tests_on_instance.sh new file mode 100755 index 0000000..f0d3448 --- /dev/null +++ b/tests/functional/launch_tests_on_instance.sh @@ -0,0 +1,440 @@ +#! /usr/bin/env bash +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck disable=SC2086 +# shellcheck disable=SC2016 +# shellcheck disable=SC2046 +set -e + +basedir=$(readlink -f "$(dirname "$0")"/../..) +# shellcheck source=lib/shell/functions.inc +. "$basedir"/lib/shell/functions.inc + +remote_ip="$1" +remote_port="$2" +account0="$3" +user_ssh_key_path="$4" +root_ssh_key_path="$5" +osh_etc="$6" +[ -n "$osh_etc" ] || osh_etc=/etc/bastion + +[ -z "$HAS_ED25519" ] && HAS_ED25519=1 +[ -z "$HAS_BLACKLIST" ] && HAS_BLACKLIST=0 +[ -z "$HAS_MFA" ] && HAS_MFA=1 +[ -z "$HAS_PAMTESTER" ] && HAS_PAMTESTER=1 +[ -z "$nocc" ] && nocc=0 +[ -z "$nowait" ] && nowait=0 +[ -z "$TARGET" ] && TARGET='' +[ -z "$TEST_SCRIPT" ] && TEST_SCRIPT='' + +# die if using an unset var +set -u + +if [ -z "$root_ssh_key_path" ] ; then + echo "Usage: $0 " + exit 1 +fi + +# does ssh work there ? +server_output=$(echo test | nc -w 1 $remote_ip $remote_port) +if echo "$server_output" | grep -q ^SSH-2 ; then + echo SSH to $remote_ip:$remote_port OK +else + echo "Port $remote_port doesn't seem open on $remote_ip, or is not SSH! ($server_output)" + exit 1 +fi + +# shellcheck disable=SC2034 # those vars are also used in all our modules +{ + account1="testu_Ser1-" + account2="tesT-user2_" + account3=teStuser3 + account4=TeStUsEr4 + uid1=9001 + uid2=9002 + uid3=9003 + uid4=9004 + group1="test_Group1-" + group2="tEst-group2_" + group3=testgrOup3 + shellaccount="test-shell_" + randomstr=randomstr_pUuGXu3tfhi5WII4_randomstr + + mytmpdir=$(mktemp -d -t bastiontest.XXXXXX) + trap 'echo CLEANING UP ; rm -rf "$mytmpdir" ; exit 255' EXIT + account0key1file="$mytmpdir/account0key1file" + account1key1file="$mytmpdir/account1key1file" + account1key2file="$mytmpdir/account1key2file" + account2key1file="$mytmpdir/account2key1file" + account3key1file="$mytmpdir/account3key1file" + account4key1file="$mytmpdir/account4key1file" + rootkeyfile="$mytmpdir/rootkeyfile" + for f in $account1key1file $account1key2file $account2key1file $account3key1file $account4key1file + do + ssh-keygen -N '' -t ecdsa -f $f -q + done + cp $user_ssh_key_path $account0key1file + ssh-keygen -y -f $user_ssh_key_path > $account0key1file.pub + cp $root_ssh_key_path $rootkeyfile + ssh-keygen -y -f $root_ssh_key_path > $rootkeyfile.pub + chmod 400 $account0key1file + + jq="jq --raw-output --compact-output --sort-keys" + js="--json-greppable" + t="timeout --foreground 30" + tf="timeout --foreground 15" + a0=" $t ssh -F $mytmpdir/ssh_config -i $account0key1file $account0@$remote_ip -p $remote_port -- $js " + a1=" $t ssh -F $mytmpdir/ssh_config -i $account1key1file $account1@$remote_ip -p $remote_port -- $js " + a1k2="$t ssh -F $mytmpdir/ssh_config -i $account1key2file $account1@$remote_ip -p $remote_port -- $js " + a2=" $t ssh -F $mytmpdir/ssh_config -i $account2key1file $account2@$remote_ip -p $remote_port -- $js " + a3=" $t ssh -F $mytmpdir/ssh_config -i $account3key1file $account3@$remote_ip -p $remote_port -- $js " + a4=" $t ssh -F $mytmpdir/ssh_config -i $account4key1file $account4@$remote_ip -p $remote_port -- $js " + a4f="$tf ssh -F $mytmpdir/ssh_config -i $account4key1file $account4@$remote_ip -p $remote_port -- $js " + r0=" $t ssh -F $mytmpdir/ssh_config -i $rootkeyfile root@$remote_ip -p $remote_port -- " +}; + +grant() { success prereq grantcmd $a0 --osh accountGrantCommand --account $account0 --command "$1"; } +revoke() { success prereq revokecmd $a0 --osh accountRevokeCommand --account $account0 --command "$1"; } + +cat >"$mytmpdir/ssh_config" <>"$mytmpdir/ssh_config" <>"$mytmpdir/ssh_config" <&1 | grep -q -- -Logfile; then + screen="screen -L -Logfile" +fi +# /checking + +testno=0 +testcount=0 +basename="" +nbfailedret=0 +nbfailedgrep=0 +nbfailedcon=0 +nbfailedlog=0 +nbfailedgeneric=0 +isbad=0 + +start_time=$(date +%s) +prefix() +{ + local elapsed=$(( $(date +%s) - start_time)) + local min=$(( elapsed / 60 )) + local sec=$(( elapsed - min * 60 )) + local totalerrors=$(( nbfailedret + nbfailedgrep + nbfailedcon + nbfailedgeneric )) + if [ "$totalerrors" = 0 ]; then + printf "%b%02dm%02d [noerror]" "$TARGET" "$min" "$sec" + else + printf "%b%02dm%02d %b[%d err]%b" "$TARGET" "$min" "$sec" "$RED" "$totalerrors" "$NOC" + fi +} + +run() +{ + if [ "$isbad" = 1 ]; then + if [ -f "$outdir/$basename.script" ]; then + printf "%b%b%b\\n" "$WHITE_ON_BLUE" "[INFO] test script follows" "$NOC" + cat "$outdir/$basename.script" + fi + printf "%b%b%b\\n" "$WHITE_ON_BLUE" "[INFO] output of the command follows" "$NOC" + cat "$outdir/$basename.log" + printf "%b%b%b\\n" "$WHITE_ON_BLUE" "[INFO] returned json follows" "$NOC" + grep "^JSON_OUTPUT=" -- $outdir/$basename.log | cut -d= -f2- | $jq . + if [ "$nocc" != 1 ]; then + printf "%b%b%b\\n" "$WHITE_ON_BLUE" "[INFO] consistency check folows" "$NOC" + cat "$outdir/$basename.cc" + fi + if test -t 0 && [ "$nowait" != 1 ]; then + printf "%b%b%b\\n" "$WHITE_ON_BLUE" "[INFO] press enter to continue" "$NOC" + read -r _ + fi + fi + isbad=0 + + testno=$(( testno + 1 )) + [ "$COUNTONLY" = 1 ] && return + name=$1 + shift + case=$1 + shift + basename=$(printf '%03d-%s-%s' $testno $name $case | sed -re "s=/=_=g") + if [ -x "$1" ] && [ "$#" -eq 1 ]; then + cp "$1" "$outdir/$basename.script" + fi + printf '%b %b*** [%03d/%03d] %b::%b %s(%b)\n' "$(prefix)" "$BOLD_CYAN" "$testno" "$testcount" "$name" "$case" "$NOC" "$*" + sleepafter=0 + [ "$name" = "scp" ] && sleepafter=2 + $screen "$outdir/$basename.log" -D -m -fn -ln flock "$outdir/$basename.log" bash -c "$* ; echo \$? > $outdir/$basename.retval ; sleep $sleepafter" + antiloop=30 + while [ ! -e "$outdir/$basename.retval" ] && [ $antiloop -gt 0 ]; do + sleep 0.1 + flock "$outdir/$basename.log" true + antiloop=$((antiloop - 1)) + done + test -e $outdir/$basename.retval || echo -1 > $outdir/$basename.retval + _bad='at /usr/share/perl|compilation error|compilation aborted|BEGIN failed|gonna crash|/opt/bastion/|sudo:|ontinuing anyway|MAKETESTFAIL' + _badexclude='/etc/shells' + # shellcheck disable=SC2126 + if [ "$(grep -qE "$_bad" $outdir/$basename.log | grep -Ev "$_badexclude" | wc -l)" -gt 0 ]; then + nbfailedgeneric=$(( nbfailedgeneric + 1 )) + fail "BAD STRING" "(generic known-bad string found in output)" + fi + if [ "$nocc" != 1 ]; then + $screen "$outdir/$basename.cc" -D -m -fn -ln flock "$outdir/$basename.cc" bash -c "$r0 /opt/bastion/bin/admin/check-consistency.pl ; echo \$? > $outdir/$basename.ccret ; $r0 test -s /var/log/bastion/bastion-warn.log ; echo \$? > $outdir/$basename.warnret ; $r0 test -s /var/log/bastion/bastion-die.log ; echo \$? > $outdir/$basename.dieret" + sleep 0.2 + flock "$outdir/$basename.cc" true + ccret=$(< $outdir/$basename.ccret) + warnret=$(< $outdir/$basename.warnret) + dieret=$(< $outdir/$basename.dieret) + if [ "$ccret" != 0 ]; then + nbfailedcon=$(( nbfailedcon + 1 )) + fail "CONSISTENCY CHECK" + fi + if [ "$warnret" != 1 ] || [ $dieret != 1 ]; then + nbfailedlog=$(( nbfailedlog + 1 )) + fail "WARN/DIE TRIGGERED" + fi + fi +} + +script() { + name=$1 + shift + section=$1 + shift + if [ "$COUNTONLY" = 1 ]; then + run $name $section true + return + fi + + tmpscript=$(mktemp -p $outdir) + echo "#! /usr/bin/env bash" > "$tmpscript" + echo "$*" >> "$tmpscript" + chmod 755 "$tmpscript" + run $name $section "$tmpscript" + rm -f "$tmpscript" +} + +retvalshouldbe() +{ + [ "$COUNTONLY" = 1 ] && return + shouldbe=$1 + got=$(< $outdir/$basename.retval) + if [ "$got" = "$shouldbe" ] ; then + ok "RETURN VALUE" "($shouldbe)" + else + nbfailedret=$(( nbfailedret + 1 )) + fail "RETURN VALUE" "(got $got instead of $shouldbe)" + fi +} + +fail() { + printf '%b %b[FAIL]%b %b\n' "$(prefix)" "$BLACK_ON_RED" "$NOC" "$*" + isbad=1 +} +ok() { + printf '%b %b[ OK ]%b %b\n' "$(prefix)" "$BLACK_ON_GREEN" "$NOC" "$*" +} + +success() +{ + run "$@" + retvalshouldbe 0 +} + +plgfail() +{ + run "$@" + retvalshouldbe 100 +} + +get_json() +{ + [ "$COUNTONLY" = 1 ] && return + grep "^JSON_OUTPUT=" -- $outdir/$basename.log | tail -n1 | cut -d= -f2- +} + +get_stdout() +{ + [ "$COUNTONLY" = 1 ] && return + cat $outdir/$basename.log +} + +json() +{ + [ "$COUNTONLY" = 1 ] && return + local jq1="" jq2="" jq3="" + local splitsort=0 + while [ $# -ge 2 ] ; do + if [ "$1" = "--splitsort" ]; then + splitsort=1 + shift + continue + elif [ "$1" = "--argjson" ] || [ "$1" = "--arg" ]; then + jq1="$1" + jq2="$2" + jq3="$3" + shift 3 + continue + fi + local filter="$1" expected="$2" + shift 2 + json=$(get_json) + set +e + if [ -n "$jq3" ]; then + got=$($jq "$jq1" "$jq2" "$jq3" "$filter" <<< "$json") + else + got=$($jq "$filter" <<< "$json") + fi + if [ "$splitsort" = 1 ]; then + expected=$(echo "$expected" | tr " " "\\n" | sort) + got=$($jq ".[]" <<< "$got" | sort) + fi + set -e + if [ -z "$json" ] ; then + nbfailedgrep=$(( nbfailedgrep + 1 )) + fail "JSON VALUE" "(no json found in output, couldn't look for key <$filter>)" + elif [ "$expected" = "$got" ] ; then + ok "JSON VALUE" "($filter => $expected) [$jq1 $jq3 $jq3]" + else + nbfailedgrep=$(( nbfailedgrep + 1 )) + fail "JSON VALUE" "(for key <$filter> wanted <$expected> but got <$got>, with optional params jq1='$jq1' jq2='$jq2' jq3='$jq3')" + fi + done +} + +pattern() +{ + [ "$COUNTONLY" = 1 ] && return + if grep -qE -- "$1" <<< "$2" ; then + ok "PATTERN" "(got '$1' in '$2')" + else + nbfailedgrep=$(( nbfailedgrep + 1 )) + fail "PATTERN" "(wanted '$1' in '$2')" + fi +} + +contain() +{ + [ "$COUNTONLY" = 1 ] && return + local specialoption='' + if [ "$1" != "REGEX" ] ; then + specialoption='-F' + else + specialoption='-E' + shift + fi + if grep -q $specialoption -- "$1" "$outdir/$basename.log"; then + ok "MUST CONTAIN" "($1)" + else + nbfailedgrep=$(( nbfailedgrep + 1 )) + fail "MUST CONTAIN" "($1)" + fi +} + +nocontain() +{ + [ "$COUNTONLY" = 1 ] && return + grepit="$1" + if grep -Eq "$grepit" "$outdir/$basename.log"; then + nbfailedgrep=$(( nbfailedgrep + 1 )) + fail "MUST NOT CONTAIN" "(should not have found string '$grepit' in output)" + else + ok "MUST NOT CONTAIN" "($grepit)" + fi +} + +configchg() +{ + success bastion configchange $r0 perl -pe "$*" -i $osh_etc/bastion.conf +} + +runtests() +{ + # backup the original default configuration on target side + now=$(date +%s) + success bastion backupconfig $r0 "dd if=$osh_etc/bastion.conf of=$osh_etc/bastion.conf.bak.$now" + + grant accountRevokeCommand + + for module in "$(dirname $0)"/tests.d/???-*.sh + do + if [ -n "$TEST_SCRIPT" ] && [ "$TEST_SCRIPT" != "$(basename "$module")" ]; then + echo "### SKIPPING MODULE $module" + continue + fi + echo "### RUNNING MODULE $module" + # shellcheck disable=SC1090 # as this is a loop, we do the check in a reversed way, see any included module + source "$module" || true + done + + # put the backed up configuration back + success bastion restoreconfig $r0 "dd if=$osh_etc/bastion.conf.bak.$now of=$osh_etc/bastion.conf" +} + +COUNTONLY=0 +echo === running unit tests === +if ! $r0 perl "$basedir/tests/unit/run.pl"; then + printf "%b%b%b\\n" "$WHITE_ON_RED" "Unit tests failed :(" "$NOC" + exit 1 +fi + +COUNTONLY=1 +testno=0 +echo === counting functional tests === +runtests +testcount=$testno + +echo === will run $testcount functional tests === +COUNTONLY=0 +testno=0 +runtests +echo + +if [ $((nbfailedret + nbfailedgrep + nbfailedcon + nbfailedgeneric)) -eq 0 ] ; then + printf "%b%b%b\\n" "$BLACK_ON_GREEN" "All tests succeeded :)" "$NOC" +else + ( + echo + printf "%b" "$WHITE_ON_RED" + echo "One or more tests failed :(" + echo "- $nbfailedret unexpected return values" + echo "- $nbfailedgrep unexpected JSON/text values" + echo "- $nbfailedcon failed consistency checks" + echo "- $nbfailedlog warn/die triggered" + echo "- $nbfailedgeneric generic bad strings found" + printf "%b" "$NOC" + ) | tee $outdir/summary +fi +echo + +set +e +set +u +(( totalerrors = nbfailedret + nbfailedgrep + nbfailedcon + nbfailedgeneric )) +[ $totalerrors -ge 255 ] && totalerrors=254 + +rm -rf "$mytmpdir" +trap EXIT +exit $totalerrors diff --git a/tests/functional/tests.d/300-activeness.sh b/tests/functional/tests.d/300-activeness.sh new file mode 100644 index 0000000..c624f57 --- /dev/null +++ b/tests/functional/tests.d/300-activeness.sh @@ -0,0 +1,57 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +testsuite_activeness() +{ + grant accountCreate + # create account1 on local bastion + success activeness create_account1 $a0 --osh accountCreate --account $account1 --uid $uid1 --public-key \""$(cat $account1key1file.pub)"\" + json .error_code OK .command accountCreate .value null + + success activeness create_account2 $a0 --osh accountCreate --account $account2 --uid $uid2 --public-key \""$(cat $account2key1file.pub)"\" + json .error_code OK .command accountCreate .value null + + success activeness create_account3 $a0 --osh accountCreate --account $account3 --uid $uid3 --always-active --public-key \""$(cat $account3key1file.pub)"\" + json .error_code OK .command accountCreate .value null + + revoke accountCreate + + configchg 's=^\\\\x22accountExternalValidationProgram\\\\x22.+=\\\\x22accountExternalValidationProgram\\\\x22:\\\\x22/opt/bastion/bin/other/doesnotexist.pl\\\\x22,=' + + success activeness test_invalid_config_but_always_active $a3 --osh info + + run activeness test_invalid_config $a1 --osh info + retvalshouldbe 101 + + configchg 's=^\\\\x22accountExternalValidationProgram\\\\x22.+=\\\\x22accountExternalValidationProgram\\\\x22:\\\\x22/opt/bastion/bin/other/check-active-account-fortestsonly.pl\\\\x22,=' + + run activeness test_account1 $a1 --osh info + retvalshouldbe 101 + + success activeness test_account2 $a2 --osh info + + success activeness test_account3 $a3 --osh info + + # for remaining tests, disable the feature + configchg 's=^\\\\x22accountExternalValidationProgram\\\\x22.+=\\\\x22accountExternalValidationProgram\\\\x22:\\\\x22\\\\x22,=' + + grant accountDelete + + # delete account1 + success realm account1_cleanup $a0 --osh accountDelete --account $account1 --no-confirm + + # delete account2 + script realm account2_cleanup "$a0 --osh accountDelete --account $account2 <<< \"Yes, do as I say and delete $account2, kthxbye\"" + retvalshouldbe 0 + + # delete account3 + success realm account3_cleanup $a0 --osh accountDelete --account $account3 --no-confirm + + revoke accountDelete +} + +testsuite_activeness diff --git a/tests/functional/tests.d/310-realm.sh b/tests/functional/tests.d/310-realm.sh new file mode 100644 index 0000000..617f9d2 --- /dev/null +++ b/tests/functional/tests.d/310-realm.sh @@ -0,0 +1,279 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +testsuite_realm() +{ + local realm_egress_group=realm + local realm_shared_account=UniVerse + + grant accountCreate + grant accountModify + + # create account1 on local bastion + success realm create_account1 $a0 --osh accountCreate --always-active --account $account1 --uid $uid1 --public-key \""$(cat $account1key1file.pub)"\" + json .error_code OK .command accountCreate .value null + success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account1 + json .error_code OK .command accountModify + + # create account2 on local bastion + success realm create_account2 $a0 --osh accountCreate --always-active --account $account2 --uid $uid2 --public-key \""$(cat $account2key1file.pub)"\" + json .error_code OK .command accountCreate .value null + success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account2 + json .error_code OK .command accountModify + + revoke accountModify + grant groupCreate + + # create realm-egress group on local bastion + success realm create_support_group $a0 --osh groupCreate --group $realm_egress_group --owner $account0 --algo rsa --size 4096 + local realm_group_key + realm_group_key=$(get_json | $jq '.value.public_key.line') + + success realm a0_delowner_egressgroup $a0 --osh groupDelOwner --group $realm_egress_group --account $account0 + + # add account1 to this group on local bastion + success realm add_account1_to_support_group $a0 --osh groupAddMember --group $realm_egress_group --account $account1 + + # add account1 to this group on local bastion + success realm add_account2_to_support_group $a0 --osh groupAddMember --group $realm_egress_group --account $account2 + + grant realmCreate + + # fail to create a realm with forbidden name + plgfail realm realm_forbidden_name $a0 --osh realmCreate --realm realm --from 0.0.0.0/0 --public-key \"$realm_group_key\" + + # fail to create account with forbidden name + plgfail realm account_forbidden_name $a0 --osh accountCreate --account realm_foobar --uid-auto --public-key \""$(cat $account1key1file.pub)"\" + + # create shared realm-account on remote bastion + success realm create_shared_account $a0 --osh realmCreate --realm $realm_shared_account --public-key \"$realm_group_key\" --from 0.0.0.0/0 + + revoke accountCreate + revoke realmCreate + + # add remote bastion ip on group of local bastion + success realm add_remote_bastion_to_group $a0 --osh groupAddServer --host 127.0.0.1 --user realm_$realm_shared_account --port 22 --group $realm_egress_group --kbd-interactive + + # attempt inter-realm connection + success realm firstconnect1 $a1 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- $js --osh info + json .value.account $account1 .value.realm $realm_shared_account + + # attempt inter-realm connection + success realm firstconnect2 $a2 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- $js --osh info + json .value.account $account2 .value.realm $realm_shared_account + + # try forbidden plugins + for plugin in selfAddPersonalAccess selfAddIngressKey selfDelIngressKey selfGenerateEgressKey selfAddPersonalAccess selfDelPersonalAccess selfPlaySession selfListSessions selfResetIngressKeys + do + run realm plugindenied $a2 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- $js --osh $plugin + retvalshouldbe 106 + json .error_message "Realm accounts can't execute this plugin, use --osh help to get the allowed plugin list" .error_code KO_RESTRICTED_COMMAND + done + + grant accountAddPersonalAccess + + # add an access to account1 from realm on remote bastion + success realm add_access_to_remote $a0 --osh accountAddPersonalAccess --account $realm_shared_account/$account1 --user-any --port-any --host 127.0.0.5 + json .error_code OK + + # fail to add a dup access to account1 from realm on remote bastion + success realm add_access_to_remote_dup $a0 --osh accountAddPersonalAccess --account $realm_shared_account/$account1 --user-any --port-any --host 127.0.0.5 + json .error_code OK_NO_CHANGE + + # list accesses remotely + success realm list_my_accesses1 $a1 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- $js --osh selfListAccesses + json .error_code OK .value[0].acl[0].addedBy $account0 .value[0].acl[0].ip 127.0.0.5 + + # list accesses remotely + success realm list_my_accesses2 $a2 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- $js --osh selfListAccesses + json .error_code OK_EMPTY + + # try to access remotely (success) + run realm access1 $a1 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- test@127.0.0.5 + retvalshouldbe 255 + nocontain 'Access denied' + contain 'will try the following accesses you have' + + # try to access remotely (fail) + run realm access2 $a2 realm_$realm_shared_account@127.0.0.1 --kbd-interactive -- test@127.0.0.5 + retvalshouldbe 107 + contain "Access denied for $realm_shared_account/$account2 to test@127.0.0.5:22" + + # create a group on remote bastion + success realm create_normal_group $a0 --osh groupCreate --group $group1 --owner $account0 --algo rsa --size 4096 + + # can't add a realm user as gk, aclk or owner of group + for acc in "realm_$realm_shared_account" "$realm_shared_account/$account1" + do + for role in Owner Gatekeeper Aclkeeper + do + plgfail realm add_${acc}_as_$role $a0 --osh groupAdd$role --group $group1 --account $acc + if [ "$acc" = "$realm_shared_account/$account1" ]; then + json .error_code ERR_REALM_USER + else + json .error_code KO_FORBIDDEN_PREFIX + fi + done + done + plgfail realm add_support_account_as_member $a0 --osh groupAddMember --group $group1 --account realm_$realm_shared_account + + # add account1 as member + success realm add_account1_as_member $a0 --osh groupAddMember --group $group1 --account $realm_shared_account/$account1 + json .error_code OK + + success realm add_account1_as_member $a0 --osh groupAddMember --group $group1 --account $realm_shared_account/$account1 + json .error_code OK_NO_CHANGE + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json --arg want "$realm_shared_account/$account1 $account0" '.value.members|sort == ($want|split(" ")|sort)' true + + # add a remote account as member + success realm add_account2_as_member $a0 --osh groupAddMember --group $group1 --account $realm_shared_account/alien + json .error_code OK + + success realm add_account2_as_member $a0 --osh groupAddMember --group $group1 --account $realm_shared_account/alien + json .error_code OK_NO_CHANGE + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json --arg want "$realm_shared_account/$account1 $realm_shared_account/alien $account0" '.value.members|sort == ($want|split(" ")|sort)' true + + # add a dummy host to the group, to see it in the accountListAccesses afterwards + success realm add_server_to_group1 $a0 --osh groupAddServer --group $group1 --host 172.16.4.4 --user nobody --port 12345 --force + success realm add_server_to_group1 $a0 --osh groupAddServer --group $group1 --host 172.16.4.4 --user nobody --port 12346 --force + + success realm removemyselffromaclk $a0 --osh groupDelAclkeeper --group $group1 --account $account0 + success realm a0_delowner_group1 $a0 --osh groupDelOwner --group $group1 --account $account0 + grant accountListAccesses + + # check access list + success realm access_list_account1 $a0 --osh accountListAccesses --account $realm_shared_account/$account1 + json '.value|[.[]|.type]|sort' '["group-member","personal"]' + json '.value[]|select(.type == "personal")|.acl[]|.ip' 127.0.0.5 + json '.value[]|select(.type == "group-member")|[.acl[]|.port]' '["12345","12346"]' + + # revoke group membership + success realm del_account1_as_member $a0 --osh groupDelMember --group $group1 --account $realm_shared_account/$account1 + json .error_code OK + + success realm del_account1_as_member_dup $a0 --osh groupDelMember --group $group1 --account $realm_shared_account/$account1 + json .error_code OK_NO_CHANGE + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json --arg want "$realm_shared_account/alien $account0" '.value.members|sort == ($want|split(" ")|sort)' true + + # check access list + success realm access_list_account1_again $a0 --osh accountListAccesses --account $realm_shared_account/$account1 + json '.value|[.[]|.type]|sort' '["personal"]' + json '.value[]|select(.type == "personal")|.acl[]|.ip' 127.0.0.5 + + # check access list + success realm access_list_account2_again $a0 --osh accountListAccesses --account $realm_shared_account/alien + json '.value|[.[]|.type]|sort' '["group-member"]' + json '.value[]|select(.type == "group-member")|[.acl[]|.port]' '["12345","12346"]' + + # revoke group membership + success realm del_account2_as_member $a0 --osh groupDelMember --group $group1 --account $realm_shared_account/alien + json .error_code OK + + success realm del_account2_as_member_dup $a0 --osh groupDelMember --group $group1 --account $realm_shared_account/alien + json .error_code OK_NO_CHANGE + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json '.value.members|sort' "[\"$account0\"]" + + # add guest access + success realm add_guest_account1 $a0 --osh groupAddGuestAccess --account $realm_shared_account/first --group $group1 --host 172.16.4.4 --user nobody --port 12345 + success realm add_guest_account1 $a0 --osh groupAddGuestAccess --account $realm_shared_account/first --group $group1 --host 172.16.4.4 --user nobody --port 12346 + + # add other guest access + success realm add_guest_account2 $a0 --osh groupAddGuestAccess --account $realm_shared_account/second --group $group1 --host 172.16.4.4 --user nobody --port 12345 + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json '.value.members|sort' "[\"$account0\"]" + json '.value.guests|sort' "[\"$realm_shared_account/first\",\"$realm_shared_account/second\"]" + + # check access list of account + success realm access_list_account1_guest $a0 --osh accountListAccesses --account $realm_shared_account/first + json '.value|[.[]|.type]|sort' '["group-guest"]' + json '.value[]|select(.type == "group-guest")|[.acl[]|.port]' '["12345","12346"]' + + # remove guest access 1 + success realm del_guest_account1 $a0 --osh groupDelGuestAccess --account $realm_shared_account/first --group $group1 --host 172.16.4.4 --user nobody --port 12345 + nocontain "removed group key" + + # check access list of account + success realm access_list_account1_guest $a0 --osh accountListAccesses --account $realm_shared_account/first + json '.value|[.[]|.type]|sort' '["group-guest"]' + json '.value[]|select(.type == "group-guest")|.acl[]|.port' 12346 + + # remove guest access 1 + success realm del_guest_account1 $a0 --osh groupDelGuestAccess --account $realm_shared_account/first --group $group1 --host 172.16.4.4 --user nobody --port 12346 + nocontain "removed group key" + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json '.value.members|sort' "[\"$account0\"]" + json '.value.guests|sort' "[\"$realm_shared_account/second\"]" + + # remove last guest access + success realm del_guest_account2 $a0 --osh groupDelGuestAccess --account $realm_shared_account/second --group $group1 --host 172.16.4.4 --user nobody --port 12345 + contain "removed group key" + + # check groupInfo + success realm groupinfo $a0 --osh groupInfo --group $group1 + json '.value.members|sort' "[\"$account0\"]" + json '.value.guests|sort' "[]" + + # check max account length + success realm add_guest_account3 $a0 --osh groupAddGuestAccess --account $realm_shared_account/verylongaccountnam --group $group1 --host 172.16.4.4 --user nobody --port 12345 + + grant accountDelete + + # delete account1 + success realm account1_cleanup $a0 --osh accountDelete --account $account1 --no-confirm + + # delete account2 + script realm account2_cleanup "$a0 --osh accountDelete --account $account2 <<< \"Yes, do as I say and delete $account2, kthxbye\"" + retvalshouldbe 0 + + revoke accountDelete + grant groupDelete + + # delete realm-egress group + run realm cleanup_realm_support_group $a0 --osh groupDelete --group $realm_egress_group --no-confirm + retvalshouldbe 0 + + revoke groupDelete + grant accountDelete + + # delete shared realm-account + script realm cleanup_shared_realm_account_fail "$a0 --osh accountDelete --account realm_$realm_shared_account <<< \"Yes, do as I say and delete realm_$realm_shared_account, kthxbye\"" + retvalshouldbe 100 + json .error_code KO_FORBIDDEN_PREFIX + + grant realmDelete + + script realm cleanup_shared_realm_account "$a0 --osh realmDelete --realm $realm_shared_account <<< \"Yes, do as I say and delete $realm_shared_account, kthxbye\"" + retvalshouldbe 0 + + revoke realmDelete + revoke accountDelete + grant groupDelete + + # delete group1 + script realm group_cleanup "$a0 --osh groupDelete --group $group1 <<< \"$group1\"" + retvalshouldbe 0 + + revoke groupDelete +} + +testsuite_realm diff --git a/tests/functional/tests.d/320-base.sh b/tests/functional/tests.d/320-base.sh new file mode 100644 index 0000000..550bd82 --- /dev/null +++ b/tests/functional/tests.d/320-base.sh @@ -0,0 +1,38 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +testsuite_base() +{ + # basic stuff and help + run base nocmd $a0 + retvalshouldbe 112 + contain "command specified and no host to connect to" + json .command null .error_code KO_NO_HOST .value null + + success osh empty $a0 -osh + contain "OSH help" + json .command help .error_code OK .value null + + success osh help1 $a0 -osh help + contain "OSH help" + json .error_code OK .command help .value null + + success osh help2 $a0 --osh help + contain "OSH help" + json .error_code OK .command help .value null + + run osh boguscmd $a0 --osh nonexistent + retvalshouldbe 104 + contain "Unknown command" + json .error_code KO_UNKNOWN_COMMAND .command null .value null + + success osh info $a0 -osh info + contain "Your alias to connect" + json .error_code OK .command info .value.account $account0 +} + +testsuite_base diff --git a/tests/functional/tests.d/330-selfkeys.sh b/tests/functional/tests.d/330-selfkeys.sh new file mode 100644 index 0000000..d94158d --- /dev/null +++ b/tests/functional/tests.d/330-selfkeys.sh @@ -0,0 +1,620 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +# now try adding a key with a from="" when server has allowOverride=1 and ingressKeyFrom="x" +# also try creating an account with it (code paths from selfAddIngressKey and accountCreate differ) +_ingress_from_test() +{ + name="$1" + ip1="$2" + ip2="$3" + keytoadd="$4" + fingerprint="$5" + + script selfAddIngressKey $name "echo '$keytoadd' | $a1 --osh selfAddIngressKey" + retvalshouldbe 0 + json .value.connect_only_from[0] $ip1 + json .value.connect_only_from[1] $ip2 + json .value.key.from_list[0] $ip1 + json .value.key.from_list[1] $ip2 + if [ "$ip1" = null ] && [ "$ip2" = null ]; then + json .value.key.prefix "" + else + json .value.key.prefix "from=\"$ip1,$ip2\"" + fi + + success selfListIngressKeys $name $a1 --osh selfListIngressKeys + json .value.keys[1].from_list[0] $ip1 + json .value.keys[1].from_list[1] $ip2 + if [ "$ip1" = null ] && [ "$ip2" = null ]; then + json .value.keys[1].prefix "" + else + json .value.keys[1].prefix "from=\"$ip1,$ip2\"" + fi + + success selfDelIngressKey $name $a1 --osh selfDelIngressKey -f "$fingerprint" + + # now on account creation + grant accountCreate + + script accountCreate $name "echo '$keytoadd' | $a0 --osh accountCreate --account $account2 --uid $uid2" + json .error_code OK .command accountCreate .value null + + revoke accountCreate + grant accountListIngressKeys + + success accountListIngressKeys $name $a0 --osh accountListIngressKeys --account $account2 + json .value.keys[0].from_list[0] $ip1 + json .value.keys[0].from_list[1] $ip2 + if [ "$ip1" = null ] && [ "$ip2" = null ]; then + json .value.keys[0].prefix "" + else + json .value.keys[0].prefix "from=\"$ip1,$ip2\"" + fi + + revoke accountListIngressKeys + grant accountDelete + + script accountDelete $name "$a0 --osh accountDelete --account $account2" "<<< \"Yes, do as I say and delete $account2, kthxbye\"" + retvalshouldbe 0 + json .error_code OK .command accountDelete + + revoke accountDelete +} + +testsuite_selfkeys() +{ + grant accountCreate + + success osh accountCreate $a0 --osh accountCreate --always-active --account $account1 --uid $uid1 --public-key \""$(cat $account1key1file.pub)"\" + json .error_code OK .command accountCreate .value null + + revoke accountCreate + grant accountModify + + # + grant accountInfo + grant auditor + + success accountssh info0 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type default + + success accountssh modifyssh1 $a0 --osh accountModify --account $account1 --egress-strict-host-key-checking no + json .error_code OK .command accountModify + + success accountssh info1 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type custom + json .value.account_egress_ssh_config.items.stricthostkeychecking no + + success accountssh modifyssh2 $a0 --osh accountModify --account $account1 --egress-strict-host-key-checking yes + json .error_code OK .command accountModify + + success accountssh info2 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type custom + json .value.account_egress_ssh_config.items.stricthostkeychecking yes + + success accountssh modifyssh3 $a0 --osh accountModify --account $account1 --egress-strict-host-key-checking ask + json .error_code OK .command accountModify + + success accountssh info3 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type custom + json .value.account_egress_ssh_config.items.stricthostkeychecking ask + + success accountssh modifyssh4 $a0 --osh accountModify --account $account1 --egress-strict-host-key-checking bypass + json .error_code OK .command accountModify + + success accountssh info4 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type custom + json .value.account_egress_ssh_config.items.stricthostkeychecking no + json .value.account_egress_ssh_config.items.userknownhostsfile /dev/null + + success accountssh modifyssh5 $a0 --osh accountModify --account $account1 --egress-strict-host-key-checking default + json .error_code OK .command accountModify + + success accountssh info5 $a0 --osh accountInfo --account $account1 + json .error_code OK .command accountInfo + json .value.account_egress_ssh_config.type default + + revoke auditor + revoke accountInfo + # + + success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account1 + json .error_code OK .command accountModify + + revoke accountModify + grant accountListEgressKeys + + success osh accountListEgressKeys $a0 --osh accountListEgressKeys --account $account1 + contain "keyline" + json .error_code OK .command accountListEgressKeys + set +e + tmpfp=$(get_json | $jq '.value|keys[0]') + set -e + json $(cat < /tmp/scphelpertmp + perl -pe "s/ssh $account0\\@\\S+/ssh -p $remote_port $account0\\@$remote_ip/" /tmp/scphelpertmp > /tmp/scphelper + chmod +x /tmp/scphelper + cat /tmp/scphelper + unset tmpb64 + fi + + run scp downloadfailnoright scp -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file $shellaccount@127.0.0.2:uptest /tmp/downloaded + retvalshouldbe 1 + contain "Sorry, but even" + + success accountAddPersonalAccess forscp $a0 --osh selfAddPersonalAccess --host 127.0.0.2 --scpdown --port 22 + + run scp downloadfailnofile scp -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file $shellaccount@127.0.0.2:uptest /tmp/downloaded + retvalshouldbe 1 + contain "through the bastion from" + contain "Error launching transfer" + contain "No such file or directory" + nocontain "Permission denied" + + success scp upload scp -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file /etc/passwd $shellaccount@127.0.0.2:uptest + contain "through the bastion to" + contain "Done," + + success scp download scp -F $mytmpdir/ssh_config -S /tmp/scphelper -i $account0key1file $shellaccount@127.0.0.2:uptest /tmp/downloaded + contain "through the bastion from" + contain "Done," + + success accountAddPersonalAccess forscpremove1 $a0 --osh selfDelPersonalAccess --host 127.0.0.2 --scpup --port 22 + success accountAddPersonalAccess forscpremove2 $a0 --osh selfDelPersonalAccess --host 127.0.0.2 --scpdown --port 22 + + # /scp + + # (forced commands) + + # ESCAPE HELL + success ssh escapehell1ae $a0 --always-escape $shellaccount@127.0.0.2 -- "\"echo 'test1;test1' ; id\"" + contain "'test1" + contain 'uid=' + contain REGEX "test1': (command )?not found" + nocontain 'test1;test1' + nocontain 'crazy' + + success ssh escapehell2ae $a0 --always-escape $shellaccount@127.0.0.2 -- "'echo \"test1;test1\" ; id'" + contain "test1;test1" + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell3ae $a0 --always-escape $shellaccount@127.0.0.2 -- "'echo \\\"test1;test1\\\" ; id'" + contain '"test1' + contain 'uid=' + contain REGEX 'test1": (command )?not found' + nocontain 'crazy' + + success ssh escapehell4ae $a0 --always-escape $shellaccount@127.0.0.2 -- "\"echo \\\"test1;test1\\\" ; id\"" + contain 'test1;test1' + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell5ae $a0 --always-escape $shellaccount@127.0.0.2 -- "\"echo \\\"test1';'test1\\\" ; id\"" + contain "test1\\';\\'test1" + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell1ne $a0 --never-escape $shellaccount@127.0.0.2 -- "\"echo 'test1;test1' ; id\"" + contain "test1;test1" + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell2ne $a0 --never-escape $shellaccount@127.0.0.2 -- "'echo \"test1;test1\" ; id'" + contain "test1;test1" + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell3ne $a0 --never-escape $shellaccount@127.0.0.2 -- "'echo \\\"test1;test1\\\" ; id'" + contain '"test1' + contain 'uid=' + contain REGEX 'test1": (command )?not found' + nocontain 'crazy' + + success ssh escapehell4ne $a0 --never-escape $shellaccount@127.0.0.2 -- "\"echo \\\"test1;test1\\\" ; id\"" + contain 'test1;test1' + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehell5ne $a0 --never-escape $shellaccount@127.0.0.2 -- "\"echo \\\"test1';'test1\\\" ; id\"" + contain "test1';'test1" + contain 'uid=' + nocontain 'not found' + nocontain 'crazy' + + success ssh escapehellnoprotect1ae $a0 --always-escape $shellaccount@127.0.0.2 "\"echo 'test1;test1' ; id\"" + contain "test1" + contain 'uid=' + contain REGEX "test1: (command )?not found" + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect2ae $a0 --always-escape $shellaccount@127.0.0.2 "'echo \"test1;test1\" ; id'" + contain "test1" + contain 'uid=' + contain REGEX 'test1: (command )?not found' + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect3ae $a0 --always-escape $shellaccount@127.0.0.2 "'echo \\\"test1;test1\\\" ; id'" + contain 'test1;test1' + contain 'uid=' + nocontain REGEX ': (command )?not found' + contain 'crazy' + + success ssh escapehellnoprotect4ae $a0 --always-escape $shellaccount@127.0.0.2 "\"echo \\\"test1;test1\\\" ; id\"" + contain "test1" + contain 'uid=' + contain REGEX 'test1: (command )?not found' + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect5ae $a0 --always-escape $shellaccount@127.0.0.2 "\"echo \\\"test1';'test1\\\" ; id\"" + contain 'test1;test1' + contain 'uid=' + nocontain 'not found' + contain 'crazy' + + success ssh escapehellnoprotect1ne $a0 --never-escape $shellaccount@127.0.0.2 "\"echo 'test1;test1' ; id\"" + contain "test1" + contain 'uid=' + contain REGEX 'test1: (command )?not found' + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect2ne $a0 --never-escape $shellaccount@127.0.0.2 "'echo \"test1;test1\" ; id'" + contain "test1" + contain 'uid=' + contain REGEX 'test1: (command )?not found' + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect3ne $a0 --never-escape $shellaccount@127.0.0.2 "'echo \\\"test1;test1\\\" ; id'" + contain 'test1;test1' + contain 'uid=' + nocontain 'not found' + contain 'crazy' + + success ssh escapehellnoprotect4ne $a0 --never-escape $shellaccount@127.0.0.2 "\"echo \\\"test1;test1\\\" ; id\"" + contain "test1" + contain 'uid=' + contain REGEX 'test1: (command )?not found' + nocontain 'test1;test1' + contain 'crazy' + + success ssh escapehellnoprotect5ne $a0 --never-escape $shellaccount@127.0.0.2 "\"echo \\\"test1';'test1\\\" ; id\"" + contain 'test1;test1' + contain 'uid=' + nocontain 'not found' + contain 'crazy' + + run ssh shellaccountatlo_badport $a0 $shellaccount@127.0.0.2 -p 223 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + run ssh shellaccountatlo_badip $a0 $shellaccount@127.0.0.1 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + run ssh shellaccountatlo_badroot $a0 root@127.0.0.2 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + run selfDelPersonalAccess mustfailnosudo $a1 -osh selfDelPersonalAccess -h 127.0.0.2 -u $shellaccount -p 22 + retvalshouldbe 106 + contain "you to be specifically granted" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + #sudo usermod -a -G osh-selfDelPersonalAccess $account1 + success selfDelPersonalAccess mustwork $a0 -osh selfDelPersonalAccess -h 127.0.0.2 -u $shellaccount -p 22 + contain "Access to $shellaccount@127.0.0.2:22 successfully removed" + json .command selfDelPersonalAccess .error_code OK .value null + + run ssh shellaccountatlo2_mustfail $a1 $shellaccount@127.0.0.2 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + success selfAddPersonalAccess mustwork $a0 -osh selfAddPersonalAccess -h 127.0.0.2 -u $shellaccount -p 226 + nocontain "already" + contain "Access to $shellaccount@127.0.0.2:226 successfully added" + json .command selfAddPersonalAccess .error_code OK .value null + + # shouldn't work + + run ssh shellaccountatlo2_badport2 $a0 $shellaccount@127.0.0.2 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + # should + + success ssh shellaccountatlo2_mustwork226 $a0 $shellaccount@127.0.0.2 -p 226 -- echo $randomstr + contain REGEX "$shellaccount@(127.0.0.2|$targethostname):226" + contain "allowed ... log on" + nocontain "Permission denied" + contain "$randomstr" + + success selfDelPersonalAccess mustwork $a0 -osh selfDelPersonalAccess -h 127.0.0.2 -u $shellaccount -p 226 + contain "Access to $shellaccount@127.0.0.2:226 successfully removed" + json .command selfDelPersonalAccess .error_code OK .value null + + run ssh shellaccountatlo2_mustfailnow $a0 $shellaccount@127.0.0.2 -p 226 -- echo $randomstr + retvalshouldbe 107 + contain "Access denied for" + nocontain "$randomstr" + json .command null .value null .error_code KO_ACCESS_DENIED + + plgfail selfAddPersonalAccess nousernoportnoforce $a0 -osh selfAddPersonalAccess -h 127.0.0.4 + nocontain "already" + contain REGEX "Couldn't connect to $account0@127.0.0.4 \\(ssh returned error (255|124)\\)" + json .command selfAddPersonalAccess .error_code ERR_CONNECTION_FAILED .value null + + success selfAddPersonalAccess nousernoport $a0 -osh selfAddPersonalAccess -h 127.0.0.4 --force + nocontain "already" + contain "Forcing add as asked" + contain "Access to 127.0.0.4 successfully added" + json .command selfAddPersonalAccess .error_code OK .value null + + run ssh rootport22 $a0 root@127.0.0.4 -- echo $randomstr + retvalshouldbe 255 + contain "allowed ... log on" + contain "Permission denied" + nocontain "$randomstr" + + run ssh anyuserport22 $a0 whatevaah@127.0.0.4 -- echo $randomstr + retvalshouldbe 255 + contain "allowed ... log on" + contain "Permission denied" + nocontain "$randomstr" + + success ssh gooduserport22 $a0 $shellaccount@127.0.0.4 -- echo $randomstr + contain "allowed ... log on" + contain "$randomstr" + + run ssh exitcode $a0 $shellaccount@127.0.0.4 -- exit 43 + retvalshouldbe 43 + contain "allowed ... log on" + + success ssh gooduserport226 $a0 $shellaccount@127.0.0.4 -p 226 -- echo $randomstr + contain "allowed ... log on" + contain "$randomstr" + + run ssh anyuseaarrport226 $a0 pokpozkpab@127.0.0.4 -p 226 -- echo $randomstr + retvalshouldbe 255 + contain "allowed ... log on" + nocontain "$randomstr" + + success selfDelPersonalAccess nousernoport $a0 -osh selfDelPersonalAccess -h 127.0.0.4 + contain "Access to 127.0.0.4 successfully removed" + json .command selfDelPersonalAccess .error_code OK .value null + + success selfDelPersonalAccess nousernoport_dupe $a0 -osh selfDelPersonalAccess -h 127.0.0.4 + nocontain "no longer has a personal access" + json .command selfDelPersonalAccess .error_code OK_NO_CHANGE .value null + + # TODO try add/del accesses with and without port/user specification + # ... then try to ssh with all combinations + + # TODO try partial group thing, and try to ssh to ip pertaining to group + success selfListAccesses oka0 $a0 --osh selfListAccesses + contain 'no registered accesses' + nocontain 'personal' + nocontain 'group-member' + nocontain 'group-guest' + json .command selfListAccesses .error_code OK_EMPTY .value null + + # FIXME with bastion config => auto-added private accesses ? + success selfListAccesses oka1 $a1 --osh selfListAccesses + contain 'no registered accesses' + nocontain 'personal' + nocontain 'group-member' + nocontain 'group-guest' + json .command selfListAccesses .error_code OK_EMPTY .value null + + success selfForgetHostKey loportnomatch $a0 --osh selfForgetHostKey --host 127.0.0.1 --port 1234 + json .command selfForgetHostKey .error_code OK '.value."[127.0.0.1]:1234".action' OK_NO_MATCH + + success selfForgetHostKey lonomatch $a0 --osh selfForgetHostKey --host 127.0.0.1 + json .command selfForgetHostKey .error_code OK '.value."127.0.0.1".action' OK_NO_MATCH + + success selfForgetHostKey lonofile $a1 --osh selfForgetHostKey --host 127.0.0.1 + json .command selfForgetHostKey .error_code OK_NO_CHANGE .value null + + success selfForgetHostKey works $a0 --osh selfForgetHostKey --host 127.0.0.2 + json .command selfForgetHostKey .error_code OK '.value."127.0.0.2".action' OK_DELETED + + success selfForgetHostKey dupe $a0 --osh selfForgetHostKey --host 127.0.0.2 + json .command selfForgetHostKey .error_code OK '.value."127.0.0.2".action' OK_NO_MATCH + + grant accountUnexpire + + success accountUnexpire nochange $a0 --osh accountUnexpire --account $account1 + json .command accountUnexpire .error_code OK_NO_CHANGE + + # artificially expire account1 + configchg 's=^\\\\x22accountMaxInactiveDays\\\\x22.+=\\\\x22accountMaxInactiveDays\\\\x22:2,=' + success bastion manuallyExpireAccount1 $r0 "touch -t 201501010101 /home/$account1/lastlog" + + run account expired $a1 --osh info + retvalshouldbe 113 + + success accountUnexpire works $a0 --osh accountUnexpire --account $account1 + json .command accountUnexpire .error_code OK + + success account unexpired $a1 --osh info + json .error_code OK + + success accountUnexpire worksnochange $a0 --osh accountUnexpire --account $account1 + json .command accountUnexpire .error_code OK_NO_CHANGE + + # try on never logged-in account (different code path) + success bastion manuallyRemoveLastlog $r0 "rm -f /home/$account1/lastlog" + + success accountUnexpire worksnochange $a0 --osh accountUnexpire --account $account1 + json .command accountUnexpire .error_code OK_NO_CHANGE + + revoke accountUnexpire + + # delete account1 + grant accountDelete + script accountDelete cleanup $a0 --osh accountDelete --account $account1 "<<< \"Yes, do as I say and delete $account1, kthxbye\"" + retvalshouldbe 0 + revoke accountDelete +} + +testsuite_selfaccesses diff --git a/tests/functional/tests.d/350-groups.sh b/tests/functional/tests.d/350-groups.sh new file mode 100644 index 0000000..190448b --- /dev/null +++ b/tests/functional/tests.d/350-groups.sh @@ -0,0 +1,1101 @@ +# vim: set filetype=sh ts=4 sw=4 sts=4 et: +# shellcheck shell=bash +# shellcheck disable=SC2086,SC2016,SC2046 +# below: convoluted way that forces shellcheck to source our caller +# shellcheck source=tests/functional/launch_tests_on_instance.sh +. "$(dirname "${BASH_SOURCE[0]}")"/dummy + +testsuite_groups() +{ + grant accountCreate + + # first we need to create account1, account2 and account3 + success accountCreate a0_create_a1 $a0 --osh accountCreate --always-active --account $account1 --uid $uid1 --public-key "\"$(cat $account1key1file.pub)\"" + json .error_code OK .command accountCreate .value null + + #grant accountModify + + #success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account1 + #json .error_code OK .command accountModify + + run accountCreate a1_fail_to_create_a2_because_not_granted $a1 --osh accountCreate --always-active --account $account2 --uid $uid2 + retvalshouldbe 106 + contain "you to be specifically granted" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + run account2access a2_cannot_connect_because_does_not_exist $a2 --osh info + retvalshouldbe 255 + nocontain "Your alias to connect" + contain "Permission denied" + + # account with no key + success accountCreate a0_create_a2_nokey $a0 --osh accountCreate --always-active --account $account2 --uid $uid2 --no-key + contain "info" + json .command accountCreate .error_code OK .value null + + grant accountListIngressKeys + + success accountListIngressKeys a0_check_a2_ingress_keys $a0 --osh accountListIngressKeys --account $account2 + json .command accountListIngressKeys .error_code OK .value.account "$account2" .value.keys '[]' + + revoke accountListIngressKeys + + grant accountDelete + script accountDelete a0_delete_a2 $a0 --osh accountDelete --account $account2 "<<< \"Yes, do as I say and delete $account2, kthxbye\"" + retvalshouldbe 0 + json .command accountDelete .error_code OK + revoke accountDelete + # /account with no key + + script accountCreate a0_create_a2 $a0 --osh accountCreate --always-active --account $account2 --uid $uid2 \< $account2key1file.pub + retvalshouldbe 0 + contain "info" + json .command accountCreate .error_code OK .value null + + script accountCreate a0_fail_to_create_a2_already_exists $a0 --osh accountCreate --always-active --account $account2 --uid $uid2 \< $account2key1file.pub + retvalshouldbe 100 + contain "already exists" + json .command accountCreate .error_code KO_ALREADY_EXISTING .value null + + #success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account2 + #json .error_code OK .command accountModify + + success account2access a2_can_access_the_bastion $a2 --osh info + contain "Your alias to connect" + json .command info .error_code OK .value.account $account2 + + # now create a3 directly, we'll need it to test groups + script accountCreate a0_create_a3 $a0 --osh accountCreate --always-active --account $account3 --uid $uid3 \< $account3key1file.pub + retvalshouldbe 0 + contain "info" + json .command accountCreate .error_code OK .value null + + #success realm modify_account1 $a0 --osh accountModify --pam-auth-bypass yes --account $account3 + #json .error_code OK .command accountModify + + success account3access a3_can_access_the_bastion $a3 --osh info + contain "Your alias to connect" + json .command info .error_code OK .value.account $account3 + + revoke accountCreate + + # now create g1 + + run groupCreate a2_fail_to_create_g1_with_a1_as_owner_because_not_granted $a2 --osh groupCreate --group $group1 --algo rsa --size 2048 --owner $account1 + retvalshouldbe 106 + contain "you to be specifically granted" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + plgfail groupCreate a0_fail_to_create_g1_with_a1_as_owner_because_bad_key_size $a0 --osh groupCreate --group $group1 --algo rsa --size 2048 --owner $account1 + contain "minimum configured key size" + json .command groupCreate .error_code KO_KEY_SIZE_TOO_SMALL .value null + + success groupCreate a0_create_g1_with_a1_as_owner $a0 --osh groupCreate --group $group1 --algo rsa --size 4096 --owner $account1 + contain "The public key of this group is" + json $(cat < should get an early deny + + run groupAddOwner a2_fail_to_addowner_a3_on_g1_early_deny_owner_cmd $a2 --osh groupAddOwner --group $group1 --account $account3 + retvalshouldbe 106 + contain "owner" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + run groupAddMember a2_fail_to_addmember_a3_on_g1_early_deny_gatekeeper_cmd $a2 --osh groupAddMember --group $group1 --account $account3 + retvalshouldbe 106 + contain "gatekeeper" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + run groupAddOwner a2_fail_to_addserver_on_g1_early_deny_aclkeeper_cmd $a2 --osh groupAddServer --group $group1 --host 1.2.3.4 --port 1234 --user nobody + retvalshouldbe 106 + contain "aclkeeper" + json .command null .value null .error_code KO_RESTRICTED_COMMAND + + # a0: create g3 and set a0, a2 and a3 as owner/gatekeeper/aclkeeper to rule out early denies for next tests + # >>>BEGIN + success groupCreate a0_create_g3_with_a0_as_owner $a0 --osh groupCreate --group $group3 --algo ecdsa --size 256 --owner $account0 + json .error_code OK .command groupCreate .value.group $group3 .value.owner $account0 + json .value.public_key.family ECDSA .value.public_key.typecode ecdsa-sha2-nistp256 .value.public_key.size 256 + #g3_pubkey=$(get_json | $jq .value.public_key.line) + #g3_fp=$( get_json | $jq .value.public_key.fingerprint) + + success groupInfo a0_info_on_g3_after_create $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + # ... we also take the opportunity to check with groupinfo that the add/del works as intended + # ... we always try to remove a3 and fail, then add it, then add it again and fail, then remove it, then remove it and fail, then add it back + + # ...... for owner + success groupDelOwner a0_del_a3_as_g3_owner_no_change $a0 --osh groupDelOwner --group $group3 --account $account3 + json .error_code OK_NO_CHANGE .command groupDelOwner .value null + + success groupAddOwner a0_add_a3_as_g3_owner $a0 --osh groupAddOwner --group $group3 --account $account3 + json .error_code OK .command groupAddOwner .value null + + success groupInfo a0_info_on_g3_after_owneradd $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0 $account3" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + success groupAddOwner a0_add_a3_as_g3_owner_no_change $a0 --osh groupAddOwner --group $group3 --account $account3 + json .error_code OK_NO_CHANGE .command groupAddOwner .value null + + success groupDelOwner a0_del_a3_as_g3_owner $a0 --osh groupDelOwner --group $group3 --account $account3 + json .error_code OK .command groupDelOwner .value null + + success groupInfo a0_info_on_g3_after_ownerdel $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + success groupAddOwner a0_add_a3_as_g3_owner $a0 --osh groupAddOwner --group $group3 --account $account3 + json .error_code OK .command groupAddOwner .value null + + success groupInfo a0_info_on_g3_after_owneradd2 $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0 $account3" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + # ...... for gatekeeper + success groupDelGatekeeper a0_del_a3_as_g3_gatekeeper_no_change $a0 --osh groupDelGatekeeper --group $group3 --account $account3 + json .error_code OK_NO_CHANGE .command groupDelGatekeeper .value null + + success groupAddGatekeeper a0_add_a3_as_g3_gatekeeper $a0 --osh groupAddGatekeeper --group $group3 --account $account3 + json .error_code OK .command groupAddGatekeeper .value null + + success groupInfo a0_info_on_g3_after_gatekeeperadd $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0 $account3" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0 $account3" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + success groupAddGatekeeper a0_add_a3_as_g3_gatekeeper_no_change $a0 --osh groupAddGatekeeper --group $group3 --account $account3 + json .error_code OK_NO_CHANGE .command groupAddGatekeeper .value null + + success groupDelGatekeeper a0_del_a3_as_g3_gatekeeper $a0 --osh groupDelGatekeeper --group $group3 --account $account3 + json .error_code OK .command groupDelGatekeeper .value null + + success groupInfo a0_info_on_g3_after_gatekeeperdel $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0 $account3" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + success groupAddGatekeeper a0_add_a3_as_g3_gatekeeper $a0 --osh groupAddGatekeeper --group $group3 --account $account3 + json .error_code OK .command groupAddGatekeeper .value null + + success groupInfo a0_info_on_g3_after_gatekeeperadd2 $a0 --osh groupInfo --group $group3 + json .error_code OK .command groupInfo .value.group $group3 + json --arg want "$account0 $account3" '.value.owners|sort == ($want|split(" ")|sort)' true + json --arg want "$account0 $account3" '.value.gatekeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.aclkeepers|sort == ($want|split(" ")|sort)' true + json --arg want "$account0" '.value.members|sort == ($want|split(" ")|sort)' true + json .value.guests '[]' + + # ...... for aclkeeper + success groupDelAclkeeper a0_del_a3_as_g3_aclkeeper_no_change $a0 --osh groupDelAclkeeper --group $group3 --account $account3 + json .error_code OK_NO_CHANGE .command groupDelAclkeeper .value null + + success groupAddAclkeeper a0_add_a3_as_g3_aclkeeper $a0 --osh groupAddAclkeeper --group $group3 --account $account3 + json .error_code OK .command groupAddAclkeeper .value null + + #success postreq a0_g3_removembr $a0 --osh groupDelMember --group $group3 --account $account0 + success postreq a0_g3_removeaclk $a0 --osh groupDelAclkeeper --group $group3 --account $account0 + success postreq a0_g3_removegk $a0 --osh groupDelGatekeeper --group $group3 --account $account0 + + # START egress passwords + + # ... for groups + + success groupGeneratePassword works1 $a0 --osh groupGeneratePassword --group $group3 --size 17 --do-it + json .command groupGeneratePassword .error_code OK .value.group $group3 + md5a=$(get_json | $jq '.value.hashes.md5crypt') + sha256a=$(get_json | $jq '.value.hashes.sha256crypt') + sha512a=$(get_json | $jq '.value.hashes.sha512crypt') + + success groupListPasswords works $a0 --osh groupListPasswords --group $group3 + json $(cat < { + "me" => { + "uid" => 99982, + "gid" => 99982, + "personal_accesses" => [qw{ me@1.2.3.4 }], + "legacy_accesses" => [qw{ me@1.2.3.5 }], + "guest_accesses" => { + "group2" => [qw{ group1@9.9.9.9 }], + } + }, + "wildcard" => { + "uid" => 99981, + "gid" => 99981, + "personal_accesses" => [qw{ 0.0.0.0/0 }], + }, + }, + "groups" => { + "group1" => { + "members" => [qw{ me }], + "accesses" => [qw{ group1@0.0.0.0/0 }], + }, + "group2" => {} + }, + } +); +OVH::Bastion::load_configuration( + mock_data => { + ingressToEgressRules => [ + [["10.19.0.0/16", "10.15.15.0/24"], ["10.20.0.0/16"], "ALLOW-EXCLUSIVE"], + [["192.168.42.0/24"], ["192.168.42.0/24"], "ALLOW"], + [["192.168.0.0/16"], ["192.168.0.0/16"], "DENY"] + ], + bastionName => "mock", + } +); + +# TESTS + +is(OVH::Bastion::config("bastionName")->value, "mock", "bastion name is mocked"); + +ok(OVH::Bastion::is_account_valid(account => "azerty")->is_ok, "is_account_valid('azerty')"); + +is(OVH::Bastion::is_account_valid(account => "in valid")->err, "KO_FORBIDDEN_CHARS", "is_account_valid('in valid')"); + +is(OVH::Bastion::is_account_valid(account => "root")->err, "KO_FORBIDDEN_NAME", "is_account_valid('root')"); + +ok(OVH::Bastion::is_bastion_account_valid_and_existing(account => "me")->is_ok, "is_bastion_account_valid_and_existing('me')"); + +is_deeply( + OVH::Bastion::is_access_granted(account => "me", user => "remote", ipfrom => "1.2.3.4", ip => "5.6.7.8", port => "9876"), + R('KO_ACCESS_DENIED', msg => 'Access denied for me to remote@5.6.7.8:9876'), + "is_access_granted(me) on denied machine" +); + +ok(OVH::Bastion::is_access_granted(account => "me", user => "me", ipfrom => "1.1.1.1", ip => "1.2.3.4", port => "9876")->is_ok, "is_access_granted(me) on allowed machine"); + +is(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "10.15.15.15", ip => "1.2.3.4", port => "9876")->err, + "KO_ACCESS_DENIED", "is_access_granted(wildcard) on disallowed machine due to ingressToEgressRules #1"); + +is(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "10.19.1.2", ip => "1.2.3.4", port => "9876")->err, + "KO_ACCESS_DENIED", "is_access_granted(wildcard) on disallowed machine due to ingressToEgressRules #1"); + +ok(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "10.19.1.2", ip => "10.20.1.2", port => "9876")->is_ok, + "is_access_granted(wildcard) on allowed machine due to ingressToEgressRules #1"); + +ok(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "192.168.42.1", ip => "192.168.42.4", port => "9876")->is_ok, + "is_access_granted(wildcard) on allowed machine due to ingressToEgressRules #2"); + +ok(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "192.168.42.1", ip => "5.6.7.8", port => "9876")->is_ok, + "is_access_granted(wildcard) on allowed machine due to ingressToEgressRules #2"); + +is(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "192.168.43.1", ip => "192.168.42.4", port => "9876")->err, + "KO_ACCESS_DENIED", "is_access_granted(wildcard) on disallowed machine due to ingressToEgressRules #3"); + +ok(OVH::Bastion::is_access_granted(account => "wildcard", user => "root", ipfrom => "192.168.43.1", ip => "5.6.7.8", port => "9876")->is_ok, + "is_access_granted(wildcard) on allowed machine due to ingressToEgressRules catch-all"); + +done_testing();