个人笔记

专注互联网

gitlab/sentry集成

gitlab/sentry都通过docker在本机运行, 本机docker 接口ip地址172.17.0.1

参考旧文

运行

运行Sentry

为了避免端口冲突, sentry端口改成8081

docker run -d --name my-sentry -e SENTRY_SECRET_KEY='<KEY>' \
-p 8081:9000 \
--link sentry-redis:redis --link sentry-postgres:postgres sentry:9.1.2

运行Gitlab

  • 这里指定了ce的版本, 避免新版本再测试出现不一致
  • 通过参数指定了hostname为docker的ip
docker run -it --rm  \
--hostname 172.17.0.1 \
--publish 8080:80 --publish 2222:22 \
--name gitlab \
--volume /gitlab/config:/etc/gitlab \
--volume /gitlab/logs:/var/log/gitlab \
--volume /gitlab/data:/var/opt/gitlab \
gitlab/gitlab-ce:12.9.10-ce.0

22端口容易被现有服务占用, 这里使用2222端口用作代码传输的ssl通道

可以修改本机的~/.ssh/config, 参考这里

$ cat ~/.ssh/config 
HOST 172.17.0.1
Port 2222

gitlab集成sentry

可以查看对应proj的问题,以及作出处理

就是gitlab里面使用sentry的功能, 所以需要去sentry赋(授)能(权)

  • 登录sentry
  • 左上角 - 个人中心 - 下拉菜单 - API key
  • 打开页面, 找到或者创建auth token, 需要project:read, event:read 两个权限

完整流程, 可以查看Gitlab Error Tracking

gitlab proj启用error tracking

  • Navigate to your project’s Settings > Operations.
  • Ensure that the Active checkbox is set.
  • In the Sentry API URL field, enter your Sentry hostname. For example, enter https://sentry.example.com (本例http://172.17.0.1:8081/) if this is the address at which your Sentry instance is available. For the SaaS version of Sentry, the hostname will be https://sentry.io.
  • In the Auth Token field, enter the token you previously generated(见上一步生成的token).
  • Click the Connect button to test the connection to Sentry and populate the Project dropdown.
  • From the Project dropdown, choose a Sentry project to link to your GitLab project.
  • Click Save changes for the changes to take effect.
  • You can now visit Operations > Error Tracking in your project’s sidebar to view a list of Sentry errors.

到此, 我们可以在gitlab看到对应项目的所有senry 问题记录, 对于gitlab-ce:12.9.10-ce.0 , 最上面可以查看/过滤问题, 也可以忽略/解决问题, 也可以基于问题新建一个gitlab issue

这些操作需要额外的event:admin 权限

而更早的gitlab-ce:12.3.5-ce.0 就只有一个简单的列表

对于本机自建的gitlab, 有可能报错Connection has failed. Re-check Auth Token and try again, 参考这里

GitLab -> Admim area -> Settings -> Network -> Outbound requests -> Allow requests to the local network from hooks and services

By checking both boxes, everything worked as documented.

Sentry 集成 gitlab

可以直接在sentry创建gitlab issue

  • In Sentry, navigate to Organization Settings > Integrations.
  • Within Integrations, find the GitLab icon and click Install.
  • In the resulting modal, click Add Installation.
  • In the pop-up window, complete the instructions to create a Sentry app within GitLab. Once you’re finished, click Next.
  • Fill out the resulting GitLab Configuration form with the following information:
    • The GitLab URL is the base URL for your GitLab instance. If using gitlab.com, enter http://172.17.0.1:8080
    • Find the GitLab Group Path in your group’s GitLab page. 自定, 必须gitlab存在
    • Find the GitLab Application ID and Secret in the Sentry app within GitLab.
      • GitLab Application ID : gitlab生成 - GitLab Application Secret : gitlab生成
    • Use this information to fill out the GitLab Configuration and click Submit.
  • In the resulting panel, click Authorize.
  • In Sentry, return to Organization Settings > Integrations. You’ll see a new instance of GitLab underneath the list of integrations.
  • Next to your GitLab Instance, click Configure. It’s important to configure to receive the full benefits of commit tracking.
  • On the resulting page, click Add Repository to select which repositories in which you’d like to begin tracking commits.

完整记录参考integrations gitlab

生成gitlab application

具体的流程参考 GitLab as OAuth2 authentication service provider

参考

Ansible实践

安装

$ sudo apt-get install software-properties-common
$ sudo apt-add-repository ppa:ansible/ansible
$ sudo apt-get update
$ sudo apt-get install ansible

配置

$ cat /etc/ansible/hosts  | sed -e "/^#/d" -e "/^$/d"
localhost ansible_connection=local

运行

$ ansible all -a "whoami"
localhost | SUCCESS | rc=0 >>
king

配置

文件路径/etc/ansible/hosts

192.168.1.50
aserver.example.org
bserver.example.org

ansible 典型参数

  • 可用性-m ping
  • 执行commands -a cmd
  • 全局替换ssh登录用户 -u
  • 全局替换ssh key --private-key
  • sudo --sudo, --sudo-user root2
  • --list-hosts 列出hosts
# 第一个参数有个特例,all表示所有
$ ansible localhost -m ping
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}
$ ansible all -a "whoami"
localhost | SUCCESS | rc=0 >>
king

$ sudo ansible all -a "whoami" -u root --sudo
localhost | SUCCESS | rc=0 >>
root

# 列出特定name的hosts
$ ansible test --list-hosts
hosts (2):
virtualbox
localhost

ssh key

$ ssh-keygen 
Generating public/private rsa key pair.
Enter file in which to save the key (/home/king/.ssh/id_rsa): /home/king/.ssh/ansible
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/king/.ssh/ansible.
Your public key has been saved in /home/king/.ssh/ansible.pub.
The key fingerprint is:
SHA256:T1liGGmbLRZI4lKIIdWn3gvWg8h/6F7hi/tgpFsNbPk king@king
The key's randomart image is:
+---[RSA 2048]----+
|o+.oo..... |
|o .o..o +o |
| . .o ..=o . |
| o.. =..+ |
| . o*+..S.o |
| o+==+. o |
| .o+oEo . |
| +o+o. |
| .o=+o |
+----[SHA256]-----+
$ ssh-copy-id -i ~/.ssh/ansible -o "PubkeyAuthentication no" king@192.168.2.131
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/king/.ssh/ansible.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
king@192.168.2.131's password:
Number of key(s) added: 1
Now try logging into the machine, with: "ssh -o 'PubkeyAuthentication no' 'king@192.168.2.131'"
and check to make sure that only the key(s) you wanted were added.
# 注意`SSH_AUTH_SOCK`
$ SSH_AUTH_SOCK=0 ssh -i /home/king/.ssh/ansible king@192.168.2.131
$ SSH_AUTH_SOCK=0 ansible 192.168.2.131 -a whoami --private-key=/home/king/.ssh/ansible 
192.168.2.131 | SUCCESS | rc=0 >>
king

配置

  • ansible_ssh_host 将要连接的远程主机名.与你想要设定的主机的别名不同的话,可通过此变量设置.
  • ansible_ssh_port ssh端口号.如果不是默认的端口号,通过此变量设置.
  • ansible_ssh_user 默认的 ssh 用户名
  • ansible_ssh_pass ssh 密码(这种方式并不安全,我们强烈建议使用 –ask-pass 或 SSH 密钥)
  • ansible_sudo_pass sudo 密码(这种方式并不安全,我们强烈建议使用 –ask-sudo-pass)
  • ansible_sudo_exe (new in version 1.8) sudo 命令路径(适用于1.8及以上版本)
  • ansible_connection 与主机的连接类型.比如:local, ssh 或者 paramiko. Ansible 1.2 以前默认使用 paramiko.1.2 以后默认使用 ‘smart’,’smart’ 方式会根据是否支持 ControlPersist, 来判断’ssh’ 方式是否可行.
  • ansible_ssh_private_key_file ssh 使用的私钥文件.适用于有多个密钥,而你不想使用 SSH 代理的情况.
  • ansible_shell_type 目标系统的shell类型.默认情况下,命令的执行使用 ‘sh’ 语法,可设置为 ‘csh’ 或 ‘fish’.

修改/etc/ansible/hosts

192.168.2.131 ansible_ssh_user=king ansible_ssh_private_key_file=/home/king/.ssh/ansible

$ SSH_AUTH_SOCK=0 ansible all -a whoami 
localhost | SUCCESS | rc=0 >>
king

192.168.2.131 | SUCCESS | rc=0 >>
king

组和别名

[test]
#192.168.2.131 ansible_ssh_user=king ansible_ssh_private_key_file=/home/king/ansible
virtualbox ansible_ssh_host=192.168.2.131 ansible_ssh_user=king ansible_ssh_private_key_file=/home/king/ansible
localhost ansible_connection=local
$ SSH_AUTH_SOCK=0 ansible test -a whoami 
localhost | SUCCESS | rc=0 >>
king

192.168.2.131 | SUCCESS | rc=0 >>
king

$ SSH_AUTH_SOCK=0 ansible test -a whoami
localhost | SUCCESS | rc=0 >>
king

virtualbox | SUCCESS | rc=0 >>
king

主机&组变量

/etc/ansible/hosts 每个主机支持一系列参数, 如果一个组的参数有重复, 可以做成组变量

[bastion]
b1 ansible_ssh_user=admin ansible_ssh_private_key_file=/home/king/.ssh/bastion
b2 ansible_ssh_user=admin ansible_ssh_private_key_file=/home/king/.ssh/bastion
b3 ansible_ssh_user=admin ansible_ssh_private_key_file=/home/king/.ssh/bastion

[bastion]
b1
b2
b3

[bastion:vars]
ansible_ssh_user=admin
ansible_ssh_private_key_file=/home/king/.ssh/bastion

模块

  • ansible-doc -l 来列出支持的模块
  • ansible-doc -s command 列出特定模块的指引

默认ansible使用的模块是command,即可以执行一些shell命令。shell和command的用法基本一样,实际上shell模块执行命令的方式是在远程使用/bin/sh来执行的

$ ansible test -m command -a whoami
localhost | SUCCESS | rc=0 >>
king

virtualbox | SUCCESS | rc=0 >>
king

$ ansible test -m shell -a whoami
localhost | SUCCESS | rc=0 >>
king

virtualbox | SUCCESS | rc=0 >>
king

最开始的ping也是一个特定的模块

copy

拷贝文件到服务器, 会自动判断是否有修改

$ ansible virtualbox -m copy -a src="~/1.pdf dest=/tmp mode=0770 owner=king group=fa backup=yes"
virtualbox | SUCCESS => {
"changed": true,
"checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"dest": "/tmp/1.pdf",
"gid": 1000,
"group": "fa",
"mode": "0770",
"owner": "king",
"path": "/tmp/1.pdf",
"size": 56111,
"state": "file",
"uid": 1000
}
$ ansible virtualbox -m copy -a src="~/1.pdf dest=/tmp mode=0770 owner=king group=fa backup=yes"
virtualbox | SUCCESS => {
"changed": false,
"checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"dest": "/tmp/1.pdf",
"gid": 1000,
"group": "fa",
"mode": "0770",
"owner": "king",
"path": "/tmp/1.pdf",
"size": 56111,
"state": "file",
"uid": 1000
}

# 目录也支持
$ ansible virtualbox -m copy -a src="~/s dest=/tmp"
virtualbox | SUCCESS => {
"changed": true,
"dest": "/tmp/",
"src": "/home/king/s"
}

如果使用”/“结尾,则拷贝的是目录中的文件,如果不以斜杠结尾,则拷贝的是目录加目录中的文件。

file

管理文件、目录的属性,也可以创建文件或目录。

ansible-doc -s file
- name: Sets attributes of files
action: file
group # file/directory的所属组
owner # file/directory的所有者
mode # 修改权限,格式可以是0644、'u+rwx'或'u=rw,g=r,o=r'等
path= # 指定待操作的文件,可使用别名'dest'或'name'来替代path
recurse # (默认no)递归修改文件的属性信息,要求state=directory
src # 创建链接时使用,指定链接的源文件
state # directory:如果目录不存在则递归创建
# file:文件不存在时,不会被创建(默认值)
# touch:touch由path指定的文件,即创建一个新文件,或修改其mtime和atime
# link:修改或创建软链接
# hard:修改或创建硬链接
$ ansible virtualbox -m file -a 'path=/tmp/x/y/z state=directory owner=king group=fa mode=0755 recurse=yes'
virtualbox | SUCCESS => {
"changed": true,
"gid": 1000,
"group": "fa",
"mode": "0755",
"owner": "king",
"path": "/tmp/x/y/z",
"size": 4096,
"state": "directory",
"uid": 1000
}

fetch

copy工作方式类似,只不过是从远程主机将文件拉取到本地端,存储时使用主机名作为目录树,且只能拉取文件不能拉取目录。

ansible-doc -s fetch
- name: Fetches a file from remote nodes
action: fetch
dest= # 本地存储拉取文件的目录。例如dest=/data,src=/etc/fstab,
# 远程主机名host.exp.com,则保存的路径为/data/host.exp.com/etc/fstab。
fail_on_missing # 当设置为yes时,如果拉取的源文件不存在,则此任务失败。默认为no。
flat # 改变拉取后的路径存储方式。如果设置为yes,且当dest以"/"结尾时,将直接把源文件
# 的basename存储在dest下。显然,应该考虑多个主机拉取时的文件覆盖情况。
src= # 远程主机上的源文件。只能是文件,不支持目录。在未来的版本中可能会支持目录递归拉取。
validate_checksum # fetch到文件后,检查其md5和源文件是否相同。
$ ansible virtualbox -m fetch -a "src=/tmp/1.pdf dest=/tmp/"
virtualbox | SUCCESS => {
"changed": true,
"checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"dest": "/tmp/virtualbox/tmp/1.pdf",
"md5sum": "d7fa85f4857527c3a58290a0be2b679a",
"remote_checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"remote_md5sum": null
}
$ ansible virtualbox -m fetch -a "src=/tmp/1.pdf dest=/tmp/ flat=yes"
virtualbox | SUCCESS => {
"changed": true,
"checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"dest": "/tmp/1.pdf",
"md5sum": "d7fa85f4857527c3a58290a0be2b679a",
"remote_checksum": "429b1cd33dcb3e71e9975e424994906f10c6d98d",
"remote_md5sum": null
}

apt

# 安装 yes/safe/full/dist
$ ansible virtualbox -m apt -a "name=dos2unix update_cache=yes" --sudo --ask-sudo-pass
SUDO password:
virtualbox | SUCCESS => {
"cache_update_time": 1591944887,
"cache_updated": true,
"changed": true,
"stderr": "",
"stdout": "正在读取软件包列表..."
# 移除
$ ansible virtualbox -m apt -a "name=dos2unix state=absent" --sudo --ask-sudo-pass
# 安装 latest/absent/present
$ ansible virtualbox -m apt -a "name=dos2unix state=present" --sudo --ask-sudo-pass

service

# running,started,stopped,restarted,reloaded
$ ansible virtualbox -m service -a "name=nginx state=stopped " --sudo --ask-sudo-pass
SUDO password:
virtualbox | SUCCESS => {
"changed": true,
"name": "nginx",
"state": "stopped"
}
$ ansible virtualbox -m service -a "name=nginx state=stopped " --sudo --ask-sudo-pass
SUDO password:
virtualbox | SUCCESS => {
"changed": false,
"name": "nginx",
"state": "stopped"
}
$ ansible virtualbox -m service -a "name=nginx state=started " --sudo --ask-sudo-pass
SUDO password:
virtualbox | SUCCESS => {
"changed": true,
"name": "nginx",
"state": "started"
}

playbook

一个playboook包含多个play, 每个play包含多个节

---
- hosts: virtualbox
tasks:
- name: execute date cmd
command: /bin/date
register: date # 注册到一个变量, 以便打印stdout
- name: execute shell whoami
shell: whoami
register: whoami
- debug: var=date.stdout_lines
- debug: msg="{{ whoami.stdout }}"
- hosts: virtualbox
tasks:
- name: execute date cmd
service: name=nginx state=started
register: nginx
- debug: msg="{{ nginx }}"
$ ansible-playbook a.yaml 

PLAY ***************************************************************************

TASK [setup] *******************************************************************
ok: [virtualbox]

TASK [execute date cmd] ********************************************************
changed: [virtualbox]

TASK [execute shell whoami] ****************************************************
changed: [virtualbox]

TASK [debug] *******************************************************************
ok: [virtualbox] => {
"date.stdout_lines": [
"2020年 06月 12日 星期五 15:50:36 CST"
]
}

TASK [debug] *******************************************************************
ok: [virtualbox] => {
"msg": "king"
}

PLAY ***************************************************************************

TASK [setup] *******************************************************************
ok: [virtualbox]

TASK [execute date cmd] ********************************************************
ok: [virtualbox]

TASK [debug] *******************************************************************
ok: [virtualbox] => {
"msg": {
"changed": false,
"name": "nginx",
"state": "started"
}
}

PLAY RECAP *********************************************************************
virtualbox : ok=8 changed=2 unreachable=0 failed=0

Bastion

[server]
server1 ansible_ssh_host=10.2.40.11
server2 ansible_ssh_host=10.2.40.22
server3 ansible_ssh_host=10.2.40.33


[server:vars]
ansible_ssh_user=admin
ansible_ssh_private_key_file=/home/king/.ssh/bastion
ansible_ssh_common_args=' -o ProxyCommand="ssh -W %h:%p bastion_admin@bastion_ip"'

参考

  1. 新手上路
  2. https://askubuntu.com/questions/762541/ubuntu-16-04-ssh-sign-and-send-pubkey-signing-failed-agent-refused-operation
  3. Running Ansible Through an SSH Bastion Host
  4. Ansible系列(一):基本配置和使用
  5. Ansible系列(二):选项和常用模块
  6. https://www.w3cschool.cn/automate_with_ansible/automate_with_ansible-db6727oq.html
  7. https://www.jianshu.com/p/446f0ab2e131

ElasticSearch学习笔记

运行

ip酌情修改

$ docker pull kibana:6.5.4
$ docker pull elasticsearch:6.5.4
$ docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" \
--name elastic elasticsearch:6.5.4
$ docker run -d -e "ELASTICSEARCH_URL=http://192.168.2.139:9200" \
--name kibana -p 5601:5601 kibana:6.5.4

7.3.0

认证, filebeat lifecycle

$ docker pull docker.elastic.co/beats/filebeat:7.3.1

$ docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" \
-e "ELASTIC_USERNAME=elastic" \
-e "ELASTIC_PASSWORD=kibana" \
-e "xpack.security.enabled=true" \
--name elastic elasticsearch:7.3.0

$ docker run -d -e "ELASTICSEARCH_HOSTS=http://192.168.2.139:9200" \
-e "ELASTICSEARCH_USERNAME=elastic" \
-e "ELASTICSEARCH_PASSWORD=kibana" \
-e "xpack.security.enabled=true" \
--name kibana -p 5601:5601 kibana:7.3.0


$ docker run -d --name=filebeat \
--volume="C:\docker\filebeat\filebeat.docker.yml:/usr/share/filebeat/filebeat.yml:ro" \
--volume="D:\log:/var/lib/docker/containers:ro" \
docker.elastic.co/beats/filebeat:7.3.0

beat直接运行

https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-7.3.0-linux-x86_64.tar.gz

$ ./filebeat-7.3.0-linux-x86_64/filebeat -c a.yml  -e -d "*"
filebeat.inputs:
- type: log
paths:
- ~/filebeat/x.log


output.elasticsearch:
hosts: ["http://127.0.0.1:9200"]
username: "filebeat"
password: "filebeat"

setup.template.enabled: false
setup.ilm.rollover_alias: "filebeat-test"
setup.ilm.check_exists: true
setup.ilm.overwrite: false
setup.ilm.pattern: "{now/d}"

ECK

all in one

需要在集群中先安装 https://www.elastic.co/guide/en/cloud-on-k8s/current/k8s-deploy-eck.html

可以酌情修改

$ kubectl apply -f https://download.elastic.co/downloads/eck/1.1.0/all-in-one.yaml

一些细节

  • 比较费内存, 如果只是配置和验证, 可以适当调小
  • 1个master和1个data 跑不起来, 非得各三个, 待查

帐号

进入pod

# 仅供测试
$ bin/elasticsearch-users useradd king -p pwd -r superuser

port forward

$ curl -k -u king -XGET "https://127.0.0.1:9200/_count?pretty  " -H 'Content-Type: application/json' -d'
{
"query": {
"match_all": {}
}
}'
Enter host password for user 'king':
{
"count" : 42,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
}
}
$ curl -k -u king:pwd -XPUT "https://127.0.0.1:9200/idx " -H 'Content-Type: application/json'
{"acknowledged":true,"shards_acknowledged":true,"index":"idx"}
$ curl -k -u king:pwd -XGET "https://127.0.0.1:9200/_count?pretty " -H 'Content-Type: application/json' -d'
{
"query": {
"match_all": {}
}
}'
{
"count" : 42,
"_shards" : {
"total" : 3,
"successful" : 3,
"skipped" : 0,
"failed" : 0
}
}

Debug

$ curl -XGET "http://192.168.2.139:9200/_count?pretty  " -H 'Content-Type: application/json' -d'
{
"query": {
"match_all": {}
}
}'
GET _count?pretty  
{
"query": {
"match_all": {}
}
}

概念

  • 索引(index)
  • 类型(type)
  • 文档
Relational DB -> Databases -> Tables -> Rows -> Columns
Elasticsearch -> Indices -> Types -> Documents -> Fields

Elasticsearch集群可以包含多个索引(indices)(数据库),每一个索引可以包含多个类型(types)(表),每一个类型包含多个文档(documents)(行),然后每个文档包含多个字段(Fields)(列)。

基本操作

创建/查询

PUT /megacorp/employee/1
{
"first_name": "John",
"last_name": "Smith",
"age": 25,
"about": "I love to go rock climbing",
"interests": [
"sports",
"music"
]
}

名字 说明 note
megacorp 索引名 文档存储的地方
employee 类型名 文档代表的对象的类
1 这个员工的ID 文档的唯一标识
GET /megacorp/employee/1
GET /megacorp/employee/_search?q=last_name:Smith
GET /megacorp/employee/_search
{
"query": {
"match": {
"last_name": "Smith"
}
}
}

搜索类型

  • query string 将所有参数通过查询字符串定义 上面第二个查询
  • DSL 使用JSON完整的表示请求体(request body) 上面第三个查询

查询与过滤

  • 结构化查询(Query DSL)
  • 结构化过滤(Filter DSL)

对比返回结果的差异

一条过滤语句会询问每个文档的字段值是否包含着特定值

{
"_index" : "megacorp",
"_type" : "employee",
"_id" : "1",
"_version" : 1,
"found" : true,
"_source" : {
"first_name" : "John",
"last_name" : "Smith",
"age" : 25,
"about" : "I love to go rock climbing",
"interests" : [
"sports",
"music"
]
}
}

一条查询语句会计算每个文档与查询语句的相关性,会给出一个相关性评分 _score ,并且按照相关性对匹配到的文档进行排序。

{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 5,
"successful" : 5,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.2876821,
"hits" : [
{
"_index" : "megacorp",
"_type" : "employee",
"_id" : "2",
"_score" : 0.2876821,
"_source" : {
"first_name" : "Jane",
"last_name" : "Smith",
"age" : 32,
"about" : "I like to collect rock albums",
"interests" : [
"music"
]
}
}
]
}
}

_version

每个文档都有一个 _version号码, 这个号码在文档被改变时加一。Elasticsearch使用这个 _version 保证所有修改都被正确排序。当一个旧版本出现在新版本之后,它会被简单的忽略。

查询

  • 全局 /_search
  • 索引 /gb/_search, /gb,us/_search, /g*,u*/_search
  • 类型 /gb/user/_search, /gb,us/user,tweet/_search
  • /_all/user,tweet/_search

结果

{
"hits": { # 实际结果
"total": 14, # 匹配到的文档总数
"hits": [{
"_index": "us", # 索引
"_type": "tweet", # 类型
"_id": "7", # ID
"_score": 1, # 排序的分数
"_source": { # 实际的内容
"date": "2014-09-17",
"name": "John Smith",
"tweet": "The Query DSL is really powerful and flexible",
"user_id": 2
}
}],
"max_score": 1 # 是所有文档匹配查询中 _score 的最大值
},
"took": 4, # 搜索请求花费的毫秒数。
"_shards": {
"failed": 0, # 失败的
"successful": 10, # 成功的
"total": 10 # 参与查询的分片数
},
"timed_out": false # 查询超时与否 `GET /_search?timeout=10ms`
}
  • _score 字段,这是相关性得分(relevance score),它衡量了文档与查询的匹配程度

分页

GET /_search?size=5
GET /_search?size=5&from=5
GET /_search?size=5&from=10

检索一部分字段

GET /website/blog/123?_source=title,text

不要元数据

GET /website/blog/123/_source

是否存在

HEAD /megacorp/employee/1

批量查询

POST /_mget
{
"docs": [
{
"_index": "megacorp",
"_type": "employee",
"_id": 2
},
{
"_index": "megacorp",
"_type": "employee",
"_id": 1,
"_source": "first_name"
}
]
}
POST /megacorp/employee/_mget
{
"ids": [
"2",
"1"
]
}
POST /megacorp/employee/_mget
{
"docs": [
{
"_id": 2
},
{
"_id": 1,
"_source": "first_name"
}
]
}

全文搜索

当不指定key时, elasticsearch会从_all的key中做全文搜索

Elasticsearch把所有字符串字段值连接起来放在一个大字符串中,它被索引为一个特殊的字段 _all

创建/更新/删除

PUT /website/blog/123
{
"title": "My first blog entry",
"text": "Just trying this out...",
"date": "2014/01/01"
}

文档在Elasticsearch中是不可变的, 如果已经存在,那么会生成一份新的, 同时递增_version, 留意下面的_versionresult

{
"_index" : "megacorp",
"_type" : "employee",
"_id" : "1",
"_version" : 2,
"result" : "updated",
}

如果我们的数据没有自然ID,我们可以让Elasticsearch自动为我们生成(注意POST)

POST /website/blog/
{
"title": "My second blog entry",
"text": "Still trying this out...",
"date": "2014/01/01"
}

只创建不更新

简单的方式是使用 POST 方法让Elasticsearch自动生成唯一 _id

其他方法

  • PUT /website/blog/123?op_type=create
  • PUT /website/blog/123/_create

成功响应状态码是 201 Created 。 失败将返回 409 Conflict

删除

DELETE /website/blog/123

冲突

elasticsearch 使用_version做乐观锁控制

index 、 get 、 delete 请求时,我们指出每个文档都有一个 _version 号码,这个号码在文档被改变时加一。Elasticsearch使用这个 _version 保证所有修改都被正确排序。当一个旧版本出现在新版本之后,它会被简单的忽略。

我们只希望文档的 _version 是 1 时更新才生效。

PUT /website/blog/1?version=1
{
"title": "My first blog entry",
"text": "Starting to get the hang of this..."
}

使用外部版本控制系统

可以在Elasticsearch的查询字符串后面添加version_type=external 来使用这些版本号。

PUT /website/blog/2?version=5&version_type=external
{
"title": "My first external blog entry",
"text": "Starting to get the hang of this..."
}

外部版本号与之前说的内部版本号在处理的时候有些不同。它不再检查 _version 是否与请求中指定的一致,而是检查是否小于指定的版本。如果请求成功,外部版本号就会被存储到 _version 中。

重试

乐观锁失败的情况下, 通常可以重试

POST /website/pageviews/1/_update?retry_on_conflict=5
{
"script" : "ctx._source.views+=1",
"upsert": {
"views": 0
}
}

局部更新

POST /website/blog/1/_update
{
"doc" : {
"tags" : [ "testing" ],
"views": 0
}
}

使用Groovy脚本?

批量更新

POST /_bulk
{ "delete": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "create": { "_index": "website", "_type": "blog", "_id": "123" }}
{ "title": "My first blog post" }
{ "index": { "_index": "website", "_type": "blog" }}
{ "title": "My second blog post" }
POST /website/_bulk
{ "index": { "_type": "log" }}
{ "event": "User logged in" }

每个子请求都被独立的执行,所以一个子请求的错误并不影响其它请求。如果任何一个请求失败,顶层的 error 标记将被设置为 true ,然后错误的细节将在相应的请求中被报告

行为 解释
create 当文档不存在时创建之
index 创建新文档或替换已有文档
update 局部更新文档
delete 删除一个文档

为什么不用一个大json

便于服务器流式操作, Elasticsearch从网络缓冲区中一行一行的直接读取数据, 并分发到不同的node去处理, 而无需等待所有的内容并序列化,造成额外的内存开销,以及时间上的延误

映射(mapping)/分析(analysis)

  • 映射(mapping)机制用于进行字段类型确认,将每个字段匹配为一种确定的数据类型( string , number , booleans , date 等)。
  • 分析(analysis)机制用于进行全文文本(Full Text)的分词,以建立供搜索用的反向索引。

Type

类型类似于rdm的表(table), 每个表有自己的定义(schema), 只不过Elasticsearch会对字段类型进行猜测,动态生成了字段和类型的映射关系, 如果没有明确定义。

最新的elasticsearch干掉了这个概念

比如2020-03-09 elasticsearch可能推导为日期格式, 那么本字段就不会匹配2020/03/09的任一个

当做全局搜索时, 从_all中匹配, 而后者会将所有字段转成字符串, 所以能匹配上

GET /_search?q=2020 # 12 个结果
GET /_search?q=2020-03-09 # 还是 12 个结果 !(只需要匹配2020就算, 只不过匹配度(score)略后)
GET /_search?q=date:2020-03-09 # 1 一个结果
GET /_search?q=date:2020 # 0 个结果 !

获取type的schema

GET /megacorp/_mapping/employee
{
"megacorp" : {
"mappings" : {
"employee" : {
"properties" : {
"about" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"age" : {
"type" : "long"
},
"first_name" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"interests" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
},
"fielddata" : true
}
}
}
}
}
}
类型 表示的数据类型
String string
Whole number byte , short , integer , long
Floating point float , double
Boolean boolean
Date date

自定义类型

自定义类型可以使你完成以下几点:

  • 区分全文(full text)字符串字段和准确字符串字段(就是分词与不分词,全文的一般要分词,准确的就不需要分词)
  • 使用特定语言的分析器(例如中文、英文、阿拉伯语,不同文字的断字、断词方式的差异)
  • 优化部分匹配字段
  • 指定自定义日期格式

type

{
"number_of_clicks": {
"type": "integer"
"index": "not_analyzed"
"analyzer": "english"
}
}

index

解释
analyzed 首先分析这个字符串,然后索引。换言之,以全文形式索引此字段。
not_analyzed 索引这个字段,使之可以被搜索,但是索引内容和指定值一样。不分析此字段。
no 不索引这个字段。这个字段不能为搜索到。

索引

每一种核心数据类型(strings, numbers, booleans及dates)以不同的方式进行索引, 在Elasticsearch中他们是被区别对待的。

确切值(Exact values) vs. 全文文本(Full text)

Elasticsearch中的数据可以大致分为两种类型:确切值全文文本

确切值是确定的,正如它的名字一样。比如一个date或用户ID,也可以包含更多的字符串比如username或email地址。确切值 “Foo” 和 “foo” 就并不相同。确切值 2014 和 2014-09-15 也不相同。确切值是很容易查询的,因为结果是二进制的 – 要么匹配,要么不匹配。

全文文本,从另一个角度来说是文本化的数据(常常以人类的语言书写),比如英语单数/复数, 近义词 或者依赖上下文推导的(鼠标/老鼠), 而对于全文数据的查询来说, 存在一个匹配程度的问题

  • 一个针对 “UK” 的查询将返回涉及 “United Kingdom” 的文档
  • 一个针对 “jump” 的查询同时能够匹配 “jumped” , “jumps” , “jumping” 甚至 “leap”
  • “johnny walker” 也能匹配 “Johnnie Walker” , “johnnie depp” 及 “Johnny Depp”
  • “fox news hunting” 能返回有关hunting on Fox News的故事,而 “fox hunting news” 也能返回关于fox hunting的新闻故事。

分析

为了方便在全文文本字段中进行这些类型的查询,Elasticsearch首先对文本分析(analyzes)

  • 使用字符过滤器(character filter)去掉无意义词(啊/哦/额/the 等)
  • 分词(analysis)
  • 转换(复数->单数, 同义词 等等)

分析器就是处理这些步骤的功能逻辑

内建分析器

  • 标准分析器, 标准分析器是Elasticsearch默认使用的分析器。对于文本分析,它对于任何语言都是最佳选择, 它根据Unicode Consortium的定义的单词边界(word boundaries)来切分文本,然后去掉大部分标点符号。最后,把所有词转为小写。
  • 简单分析器, 简单分析器将非单个字母的文本切分,然后把每个词转为小写
  • 空格分析器, 空格分析器依据空格切分文本。它不转换小写。
  • 语言分析器

测试验证

GET /_analyze
{
"analyzer" : "standard",
"text" : "Text to analyze!"
}
{
"tokens" : [
{
"token" : "text",
"start_offset" : 0,
"end_offset" : 4,
"type" : "<ALPHANUM>",
"position" : 0
},
{
"token" : "to",
"start_offset" : 5,
"end_offset" : 7,
"type" : "<ALPHANUM>",
"position" : 1
},
{
"token" : "analyze",
"start_offset" : 8,
"end_offset" : 15,
"type" : "<ALPHANUM>",
"position" : 2
}
]
}

指定分析器

当Elasticsearch在你的文档中探测到一个新的字符串字段,它将自动设置它为全文 string 字段并用 standard 分析器分析。

创建一个新索引,指定 tweet 字段的分析器为 english

PUT /gb
{
"mappings": {
"tweet": {
"properties": {
"tweet": {
"type": "string",
"analyzer": "english"
},
"date": {
"type": "date"
},
"name": {
"type": "string"
},
"user_id": {
"type": "long"
}
}
}
}
}

定在 tweet 的映射中增加一个新的 not_analyzed 类型的文本字段,叫做 tag

PUT /gb/_mapping/tweet
{
"properties": {
"tag": {
"type": "string",
"index": "not_analyzed"
}
}
}

复杂搜索/聚合

集群和节点

  • 节点(node)是一个运行着的Elasticsearch实例
  • 集群(cluster)是一组具有相同 cluster.name 的节点集合,
  • 分片(shards), 个最小级别“工作单元(worker unit)”,它只是保存了索引中所有数据的一部分, 索引(index)——一个存储关联数据的地方。实际上,索引只是一个用来指向一个或多个分片(shards)的“逻辑命名空间(logical namespace)”

做为用户,我们能够与集群中的任何节点通信。每一个节点都知道文档存在于哪个节点上,它们可以转发请求到相应的节点上。我们访问的节点负责收集各节点返回的数据,最后一起返回给客户端。

Node

  • Master-eligible node A node that has node.master set to true (default), which makes it eligible to be elected as the master node, which controls the cluster.
  • Data node A node that has node.data set to true (default). Data nodes hold data and perform data related operations such as CRUD, search, and aggregations.
  • Ingest node
  • Machine learning node

在可用性要求高的集群中, 搞一个Master Only的集群?

分片

分片就是一个Lucene实例,并且它本身就是一个完整的搜索引擎。我们的文档存储在分片中,并且在分片中被索引,但是我们的应用程序不会直接与它们通信,取而代之的是,直接与索引通信。

  • 主要分片(primary shard)
  • 复制分片(replica shard),复制分片只是主分片的一个副本,它可以防止硬件故障导致的数据丢失,同时可以提供读请求,比如搜索或者从别的shard取回文档。
PUT /blogs
{
"settings" : {
"number_of_shards" : 3, # 主要分片数
"number_of_replicas" : 1 # 每个主要分片的副本数量
}
}

文档存储在分片中,然后分片分配到你集群中的节点上。当你的集群扩容或缩小,Elasticsearch将会自动在你的节点间迁移分片,以使集群保持平衡。当索引创建完成的时候,主分片的数量就固定了,但是复制分片的数量可以随时调整。

集群状态

因为测试环境只有一个节点, 所以复制分片(replica shards)全部都不可用(unassigned 状态)

在同一个节点上保存相同的数据副本是没有必要的,如果这个节点故障了,那所有的数据副本也会丢失

GET /_cluster/health
# resp
{
"cluster_name" : "docker-cluster",
"status" : "yellow", <----
"timed_out" : false,
"number_of_nodes" : 1,
"number_of_data_nodes" : 1,
...
}
颜色 意义
green 所有主要分片和复制分片都可用
yellow 所有主要分片可用,但不是所有复制分片都可用
red 不是所有的主要分片都可用

接上,假如副本数量是1, 当新加一个node后, 那么elasticsearch会自动创建复制分片, 也可能转移部分主分片过去, 在当前节点分配复制分片, 此时所有的分片都已经可用, 那么健康状态就是green

此时的集群就相当于实现了横向扩展, (请求任何一个节点都能读写文档), 高可用(一个节点挂了, 还有另外一份, 并且支持从复制分片重新恢复主分片)

横向扩展

PUT /blogs/_settings
{
"number_of_replicas" : 2
}

在保持两个node的情况下, 因为没有足够的node分配(复制)分片, 所以集群状态又将变黄, 新加node后, 又将变绿, 此时集群的负载能力将进一步增强

故障恢复

假如所有的节点都支持master选举和数据存储

现在master节点挂了, 那么集群将变红, 因为不是所有的主分片都可用(如果master有主分片), 接下来

  • 选择产生新的master节点
  • 把丢失的主分片对应的复制分片升级成主分片(选择策略? 如果有多个复制分片)

路由

新的版本的实现有改动?

当你索引一个文档,它被存储在单独一个主分片上。Elasticsearch通过根据文档的key, 并使用一个路由算法(hash)来判断文档在哪个node, 简单地

shard = hash(routing) % number_of_primary_shards

routing 值是一个任意字符串,它默认是 _id 但也可以自定义。这个 routing 字符串通过哈希函数生成一个数字,然后除以主切片的数量得到一个余数(remainder),余数的范围永远是0到number_of_primary_shards - 1 ,这个数字就是特定文档所在的分片。这也解释了为什么主分片的数量只能在创建索引时定义且不能修改:如果主分片的数量在未来改变了,所有先前的路由值就失效了,文档也就永远找不到了。

或者迁移数据?

所有的文档API( get 、 index 、 delete 、 bulk 、 update 、 mget )都接收一个 routing 参数,它用来自定义文档到分片的映射。自定义路由值可以确保所有相关文档——例如属于同一个人的文档——被保存在同一分片上。

写流转

我们将接收外部请求节点称之为请求节点(requesting node)

当我们发送请求,最好的做法是循环通过所有节点请求,这样可以平衡负载。

  • 客户端给 请求节点(node1) 发送新建、索引或删除请求。
  • 找到分片对应的node, 分发给对应node(node2)
  • node2完成主要分片的增删改
  • node2同步修改到所有的复制分片
  • 依次同步返回, 最后由请求节点返回给客户端

异步

当更新完主要分片后, 数据基本就安全了, 等待所有的复制分片同步完成, 会拖慢客户端请求时间

update API还接受 routing 、 replication 、 consistency 和 timout 参数。

replication 默认的值是 sync 。这将导致主分片得到复制分片的成功响应后才返回。

如果你设置 replication 为 async ,请求在主分片上被执行后就会返回给客户端。它依旧会转发请求给复制节点,但你将不知道复制节点成功与否。

默认主分片在尝试写入时需要规定数量(quorum)或过半的分片(可以是主节点或复制节点)可用。 consistency 允许的值为 one (只有一个主分片), all (所有主分片和复制分片)或者默认的 quorum 或过半分片。

当分片副本不足时会怎样?Elasticsearch会等待更多的分片出现。默认等待一分钟。如果需要,你可以设置 timeout 参数让它终止的更早: 100 表示100毫秒, 30s 表示30秒。

读流转

文档能够从主分片或任意一个复制分片被检索。

  • 客户端给 请求节点(node1) 发送查询请求。
  • node1根据文档id找到对应的node(包含主要分片/复制分片)
  • 分发请求到对应节点, 对于读请求,为了平衡负载,请求节点会为每个请求选择不同的分片——它会循环所有分片副本。
  • 等待返回

可能的情况是,一个被索引的文档已经存在于主分片上却还没来得及同步到复制分片上。这时复制分片会报告文档未找到,

Jaeger初探

Jaeger 是Uber 开源的分布式追踪系统,兼容OpenTracing 标准, 官网https://github.com/jaegertracing/jaeger

运行Sample

$ docker run -d --name jaeger \
-e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \
-p 5775:5775/udp \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 14268:14268 \
-p 14250:14250 \
-p 9411:9411 \
jaegertracing/all-in-one:1.17

可以打开http://localhost:16686访问Jeager UI

概念

这些概念可以对照Jeager UI加深/帮助理解

Traces in OpenTracing are defined implicitly by their Spans. In particular, a Trace can be thought of as a directed acyclic graph (DAG) of Spans, where the edges between Spans are called References.

Each Span encapsulates the following state:

  • An operation name
  • A start timestamp
  • A finish timestamp
  • A set of zero or more key:value Span Tags. The keys must be strings. The values may be strings, bools, or numeric types.
  • A set of zero or more Span Logs, each of which is itself a key:value map paired with a timestamp. The keys must be strings, though the values may be of any type. Not all OpenTracing implementations must support every value type.
  • A SpanContext (see below)
  • References to zero or more causally-related Spans (via the SpanContext of those related Spans)

Each SpanContext encapsulates the following state:

  • Any OpenTracing-implementation-dependent state (for example, trace and span ids) needed to refer to a distinct Span across a process boundary
  • Baggage Items, which are just key:value pairs that cross process boundaries
Causal relationships between Spans in a single Trace


[Span A] ←←←(the root span)
|
+------+------+
| |
[Span B] [Span C] ←←←(Span C is a `ChildOf` Span A)
| |
[Span D] +---+-------+
| |
[Span E] [Span F] >>> [Span G] >>> [Span H]



(Span G `FollowsFrom` Span F)
Temporal relationships between Spans in a single Trace


––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–> time

[Span A···················································]
[Span B··············································]
[Span D··········································]
[Span C········································]
[Span E·······] [Span F··] [Span G··] [Span H··]

参考

Golang

grpc sample

$ protoc -I ./ helloworld.proto --go_out=plugins=grpc:./

Jeager Export

参考 https://github.com/census-ecosystem/opencensus-go-exporter-jaeger/blob/master/example/main.go

import (
"log"
"contrib.go.opencensus.io/exporter/jaeger"
"go.opencensus.io/trace"
)

func initTracer(name, agent string, sampling float64) (*jaeger.Exporter, error) {

trace.ApplyConfig(trace.Config{DefaultSampler: trace.AlwaysSample()})
// Register the Jaeger exporter to be able to retrieve
// the collected spans.
exporter, err := jaeger.NewExporter(jaeger.Options{
CollectorEndpoint: agent,
Process: jaeger.Process{
ServiceName: name,
},
})
if err != nil {
log.Fatal(err)
}
trace.RegisterExporter(exporter)
return exporter, nil
}

func main() {
// 初始化trace服务器
exporter, _ := initTracer("gotest", "http://localhost:14268/api/traces", 1)
defer exporter.Flush()
}

trace

func dosth(ctx context.Context) {
// 一个子span
_, span := trace.StartSpan(ctx, "baidu")
span.AddAttributes(trace.StringAttribute("name", "doget2"))
span.SetStatus(trace.Status{Code: trace.StatusCodeOK, Message: "no error"})
span.Annotate([]trace.Attribute{
trace.Int64Attribute("len", int64(123456)),
trace.StringAttribute("name", "dosth"),
}, "Annotate")

defer span.End()
http.Get("http://www.baidu.com")
}

Http trace

在python做各种三方库注入比较容易, golang需要手动处理, 参考https://awesomeopensource.com/project/census-instrumentation/opencensus-go#Getting%20Started

client

较为完善的例子可参考https://cloud.google.com/solutions/using-distributed-tracing-to-observe-microservice-latency-with-opencensus-and-stackdriver-trace

import (
"context"
fmt "fmt"
"io/ioutil"
"log"
"net/http"

"go.opencensus.io/plugin/ochttp/propagation/tracecontext"
"go.opencensus.io/trace"
)

func doget(ctx context.Context) {
// 调用子http服务的span
name := "httpget"
_, span := trace.StartSpan(ctx, name)
url := "http://127.0.0.1:12345/sub"
span.AddAttributes(trace.StringAttribute("url", url))
defer span.End()

// 调用Http服务
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Fatalf("%v", err)
}
req = req.WithContext(ctx)

format := &tracecontext.HTTPFormat{}
format.SpanContextToRequest(span.SpanContext(), req)

client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
log.Fatalf("%v", err)
}

defer resp.Body.Close()
s, err := ioutil.ReadAll(resp.Body)
fmt.Printf(string(s))
}

Server

func hander(ctx context.Context, name string) func(http.ResponseWriter, *http.Request) {
// 子服务router
return func(w http.ResponseWriter, r *http.Request) {
httpFormat := &tracecontext.HTTPFormat{}
sc, ok := httpFormat.SpanContextFromRequest(r)
var span *trace.Span = nil
if ok {
// 从header里面取spanContext, 做好衔接
ctx, span = trace.StartSpanWithRemoteParent(ctx, name, sc)
} else {
ctx, span = trace.StartSpan(ctx, name)
}

// 增加Tag
span.AddAttributes(trace.StringAttribute("name", fmt.Sprintf("name: %s", name)))
span.SetStatus(trace.Status{Code: trace.StatusCodeOK, Message: "no error"})
defer span.End()

fmt.Fprintf(w, "Hello there!\n")
}
}

grpc trace

一个完整的例子可以参考https://opencensus-website-snapshot.firebaseapp.com/gogrpc/

Client

import (
"context"
"fmt"
"log"

"go.opencensus.io/plugin/ocgrpc"
"go.opencensus.io/stats/view"
"go.opencensus.io/trace"
grpc "google.golang.org/grpc"
)
func grpc_client(ctx context.Context, address, name string) {
// 一个子span
ctx, span := trace.StartSpan(ctx, name)
span.AddAttributes(trace.StringAttribute("grpc", fmt.Sprintf("name: %s", name)))
defer span.End()

if err := view.Register(ocgrpc.DefaultClientViews...); err != nil {
log.Fatalf("Failed to register ocgrpc client views: %v", err)
}
conn, err := grpc.Dial(
address,
grpc.WithStatsHandler(&ocgrpc.ClientHandler{
StartOptions: trace.StartOptions{
Sampler: trace.AlwaysSample(),
},
}),
grpc.WithInsecure(),
)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

// 调用实际的rpc接口
// grpc 生成的代码接口
c := NewGreeterClient(conn)
r, err := c.SayHello(ctx, &HelloRequest{Name: name})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.GetMessage())
}

Server

func grpc_server(addr string) {
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("failed to listen: %v", err)
}

if err := view.Register(ocgrpc.DefaultServerViews...); err != nil {
log.Fatalf("Failed to register ocgrpc server views: %v", err)
}

s := grpc.NewServer(grpc.StatsHandler(&ocgrpc.ServerHandler{}))
// grpc生成的代码
RegisterGreeterServer(s, &server{})
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}

Flask

Flask==0.12.4
opencensus==0.7.6
opencensus-ext-flask==0.7.3
opencensus-ext-jaeger==0.7.1
opencensus-ext-sqlalchemy==0.1.2
opencensus-ext-grpc==0.7.1
opencensus.ext.requests==0.7.2

初始化

def init_opencensus_tracing(app):
from opencensus.ext.jaeger.trace_exporter import JaegerExporter
from opencensus.ext.flask.flask_middleware import FlaskMiddleware
from opencensus.trace import config_integration, samplers

# 配置注入
INTEGRATIONS = ['sqlalchemy', 'requests']
exporter = JaegerExporter(
service_name='flask_sample',
agent_host_name="127.0.0.1",
agent_port=6831,
)
sampler = samplers.ProbabilitySampler(rate=1)
FlaskMiddleware(
app,
exporter=exporter,
sampler=sampler,
blacklist_paths=['health'],
)
config_integration.trace_integrations(INTEGRATIONS)

当使用sqlalchemy/requests做数据库操作/Http(s)操作时, 会自动生成新的span

Python Grpc客户端

grpc不支持配置集成(config integration)

参考https://github.com/census-instrumentation/opencensus-python/issues/677

This seems to be a doc issue, the grpc integration is done by explicitly using the interceptors (refer to the examples) instead of through config_integration.

$ python -m grpc_tools.protoc --python_out ./ \
--grpc_python_out ./ -I. ./helloworld.proto
from opencensus.ext.grpc import client_interceptor
import grpc
from .helloworld_pb2 import HelloRequest
from .helloworld_pb2_grpc import GreeterStub

options = [("grpc.lb_policy_name", "round_robin")]

global qrcoder_client
qchannel = grpc.insecure_channel("127.0.0.1:12346", options)
# 注入trace
trace_interceptor = client_interceptor.OpenCensusClientInterceptor(
host_port="127.0.0.1:12346"
)
qchannel = grpc.intercept_channel(qchannel, trace_interceptor)
qrcoder_client = GreeterStub(qchannel)
qrcoder_client.SayHello(HelloRequest(name="world"))

其他Export

上面一直都使用的Jaeger, 其他包括参见https://opencensus.io/exporters/supported-exporters/

ResfulApi测试框架tavern

Sample

测试用例由一个测试名称、一个或多个阶段(stage)组成,每个阶段都有一个名称、一个请求和一个响应。举个简单的例子

test_name: Get some fake data from the JSON placeholder API

stages:
- name: Make sure we have the right ID
request:
url: https://jsonplaceholder.typicode.com/posts/1
method: GET
response:
status_code: 200
body:
id: 1
save:
body:
returned_id: id

此用例表示, 使用request.method方法向request.url请求, 期望成功返回(status等于200), 并且返回的内容(json)中, id等于1, 并且把id存在变量returned_id

Request

请求段描述发送到服务器的内容

  • url
  • json post/put/..的json内容(如果是json格式)
  • param get方法的参数
  • data form参数
  • headers
  • method

Response

响应段描述了我们期望得到的结果。

  • status_code 期望的status, 缺省200, 也可能201或其他
  • body 期待返回的内容
  • headers 期待返回的header
  • redirect_query_params 期望重定向URL的参数

Save

The save block can save values from the response for use in future requests. Things can be saved from the body, headers, or redirect query parameters. When used to save something from the json body, this can also access dictionaries and lists recursively. If the response is

save块可以从响应中保存结果,以便在将来的请求中使用。可以从body、header或重定向查询参数中保存结果。当用于从json中保存内容时,它还可以递归地访问字典和列表, 获取内部某个值。如果响应是

{
"thing": {
"nested": [
1, 2, 3, 4
]
}
}

可以从响应块中的某个值保存到first val的值中

response:
save:
body:
first_val: thing.nested[0]

也可以使用函数调用来保存数据,见后续

变量

全局变量

# common.yaml
---

name: 全局定义
description: |
测试

variables:
global_url: http://www.baidu.com
---
test_name: 登录/个人信息

includes:
- !include common.yaml

stages:
- name: 登录
request:
url: "{global_url:s}/user/login"

保存变量

留意global_token

stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
json:
username: "user"
password: "pwd"
response:
status_code: 200
save:
body:
global_token: data.token # 把token存下来后续调用
- name: 个人信息
request:
url: "{global_url:s}/user/info"
method: GET
headers:
Authorization: "{global_token}" # 获取之前存下来的token

Request变量

可以从当前的request参数中获取值用作判断

留意tavern.request_vars

stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
json:
username: "user"
password: "pwd"
response:
status_code: 200
body:
code: 0
message: OK
data:
username: "{tavern.request_vars.json.username}" # 返回的username和参数一致

函数保存结果

stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
json:
username: "user"
password: "pwd"
response:
status_code: 200
body:
code: 0
message: OK
save:
$ext:
function: base:save_test_data
# export PYTHONPATH=$PYTHONPATH:.
from box import Box

def save_test_data(response):
return Box({"test_user_id": response.json()["data"]["userID"]})

函数校验

支持自定义参数

def check_message(response):
assert response.json().get("message") == "OK"

def check_code(response,**kwargs):
assert response.json().get("code") == kwargs["expect"]
stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
json:
username: "user"
password: "pwd"
response:
status_code: 200
verify_response_with: # 单函数判断 export PYTHONPATH=$PYTHONPATH:.
function: base:check_message
- name: 个人信息
request:
url: "{global_url:s}/user/info"
method: GET
response:
status_code: 200
body:
code: 0
message: OK
verify_response_with: # 多函数判断
- function: base:check_message
- function: base:check_code
extra_kwargs:
expect: 0 # 期望 code = 0 (自定义参数)

生成header

$ext 表示下面的内容是函数生成器

from box import Box

def get_test_header():
auth_header = {
"how": "are you"
}
return Box(auth_header)
stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
headers:
$ext:
function: base:get_test_header

环境变量

留意tavern.env_vars

stages:
- name: 登录
request:
url: "{global_url:s}/user/login"
method: POST
headers:
Authorization: "Basic {tavern.env_vars.SECRET_CI_COMMIT_AUTH}"

参考

  1. https://github.com/taverntesting/tavern
  2. https://tavern.readthedocs.io/en/latest/basics.html
  3. https://pypi.org/project/python-box/

nginx配置ldap登录授权

本文代码来自https://github.com/nginxinc/nginx-ldap-auth

nginx内建了auth_request实现了权限控制拦截功能, 这适用于需要在无认证的服务前增加访问控制, 比如Kibana无默认的权限控制, 但是公司的日志显然不能裸奔

实现包含三部分

  • nginx(auth_request)
  • (较通用)的认证服务
  • 实际的业务服务

业务服务

文中的业务服务比较简单, 用Python写了个简单的web服务, 以及用于客户输入帐号密码的表单页面

Nginx

  • nginx 绑定端口8081 供外部访问
  • auth_request做全量拦截, 如果认证失败, 抛出401
  • nginx 将401重定向到/login, 即业务服务中的表单
  • 表单提交又回到auth_request
    • 若存在cookie, 从cookie中解码出帐号密码, 然后ldap认证
    • 对于首次登录, 则从form表单中获取帐号密码
    • 认证成功,将帐号密码保存在cookie中, 避免重复输入帐号密码
upstream backend {
server 127.0.0.1:9000;
}
server {
listen 8081;

location / {
auth_request /auth-proxy;
error_page 401 =200 /login;
proxy_pass http://backend/;
}

location /login {
proxy_pass http://backend/login;
proxy_set_header X-Target $request_uri;
}

location = /auth-proxy {
internal;

proxy_pass http://127.0.0.1:8888;

proxy_pass_request_body off;
proxy_set_header Content-Length "";

proxy_cache_key "$http_authorization$cookie_nginxauth";

proxy_set_header X-Ldap-URL "ldap://127.0.0.1";

proxy_set_header X-Ldap-BaseDN "ou=lixin,dc=example,dc=org";

proxy_set_header X-Ldap-BindDN "cn=admin,ou=tech,ou=lixin,dc=example,dc=org";

proxy_set_header X-Ldap-Template "(cn=%(username)s)";

proxy_set_header X-Ldap-BindPass "123456";

proxy_set_header X-CookieName "nginxauth";
proxy_set_header Cookie nginxauth=$cookie_nginxauth;
}
}

nginx 将所有ldap的参数,以header的形式传给认证服务, 这样确保认证服务相对通用

认证服务

  • 设定一个header头-参数的映射表, 以及服务启动传入的缺省值
  • 每次请求,都动态获取这些参数, 连同(从form表单/cookie获取到的)帐号密码存入临时变量ctx
  • 使用ctx的参数做ldap认证
  • 参数错误/认证失败 返回401

问题

这显然只是一个sample, 不可能在生产环境中实施, 因为安全问题和性能问题比较明显

首先将帐号密码存在cookie中显然不合适, 可以考虑对帐号密码加密再写cookie

更好的方法是将帐号密码存在内部存储, 比如redis中,然后把redis的key写cookie

此时仍然存在问题, 因为每个请求都要走一遍ldap认证, 对响应时延影响较大

一种折中的方案是, 认证成功之后, 生成一个session并存入redis, session_id写cookie, 用户完整信息在session中, 但仍有不足, 比如ldap删除某个用户, 并不会立即反映到业务系统来

sentry配置ldap登录授权

快速安装sentry见http://blog.kimq.cn/2017/03/10/sentry/

了解ldap见http://blog.kimq.cn/2020/02/02/openldap/

安装插件

进入docker

$ docker exec -it my-sentry /bin/bash
# 最好先修改源
$ apt update
$ apt-get install libsasl2-dev python-dev libldap2-dev libssl-dev
# for debug
$ apt-get vim procps -y
# 安装sentry插件
pip install https://github.com/Banno/getsentry-ldap-auth/archive/2.8.1.zip

getsentry-ldap-auth的版本自行评估

Debug

/usr/local/lib/python2.7/site-packages/sentry_ldap_auth$ find . -type f
./backend.py
./__init__.py

若有问题, 可自行修改代码, 然后重启docker镜像, 日志查看

$ docker logs -f my-sentry

配置ldap群组和帐号

两个群组

  • owner_group 对应sentry的owner角色
  • admin_group 对应sentry的admin角色

admin属于owner_group, king1属于admin_group

$ ldapsearch -x -H ldap://localhost  -w 123456 -D cn=admin,ou=tech,ou=lixin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org"
# admin_group, lixin, example.org
dn: cn=admin_group,ou=lixin,dc=example,dc=org
objectClass: groupOfNames
objectClass: top
cn: admin_group
member: cn=king1,ou=tech,ou=lixin,dc=example,dc=org

# owner_group, lixin, example.org
dn: cn=owner_group,ou=lixin,dc=example,dc=org
objectClass: groupOfNames
objectClass: top
cn: owner_group
member: cn=admin,ou=tech,ou=lixin,dc=example,dc=org

# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: admin
userPassword:: e1NTSEF9ekRETXFGME4ydVNHUWZzRmxZSVlKVmVoS3BWbENxaGk=

# king1, tech, lixin, example.org
dn: cn=king1,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: king1

# king2, tech, lixin, example.org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: king2

配置Sentry服务器

将下列配置附到/etc/sentry/sentry.conf.py 后面

#################################################################
# ldap auth
import ldap
from django_auth_ldap.config import LDAPSearch, GroupOfNamesType

# ldap服务器地址
AUTH_LDAP_SERVER_URI = 'ldap://172.17.0.1'
# 具有查询权限的内部帐号
AUTH_LDAP_BIND_DN = 'cn=admin,ou=tech,ou=lixin,dc=example,dc=org'
# 密码
AUTH_LDAP_BIND_PASSWORD = '123456'

# 用户登录的过滤条件, 我们输入的帐号是ldap的`cn`
# @attention user占位符别修改
AUTH_LDAP_USER_SEARCH = LDAPSearch(
'ou=tech,ou=lixin,dc=example,dc=org',
ldap.SCOPE_SUBTREE,
'(cn=%(user)s)',
)

# 查找组的过滤条件
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
'ou=lixin,dc=example,dc=org',
ldap.SCOPE_SUBTREE,
'(objectClass=groupOfNames)'
)

# GroupOfNamesType
# NestedGroupOfNamesType
# GroupOfUniqueNamesType
# NestedGroupOfUniqueNamesType
# ActiveDirectoryGroupType
# NestedActiveDirectoryGroupType
# OrganizationalRoleGroupType
# NestedOrganizationalRoleGroupType
AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()

# 如果设置了AUTH_LDAP_REQUIRE_GROUP那么只有这个组的成员才会登录成功
AUTH_LDAP_REQUIRE_GROUP = None
# AUTH_LDAP_DENY_GROUP正好相反,这个组的用户登录都会被拒绝。
AUTH_LDAP_DENY_GROUP = None

# 用于映射sentry用户字段, 因为ldap测试的对象比较简单, 这里全部都用`cn`
AUTH_LDAP_USER_ATTR_MAP = {
'name': 'cn',
'email': 'cn'
}

AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600

# Sentry缺省的Organization, 填写错误会导致登录后无权限, 一般不用修改
AUTH_LDAP_DEFAULT_SENTRY_ORGANIZATION = u'Sentry'
# 自动加入到这个缺省的群组
AUTH_LDAP_SENTRY_SUBSCRIBE_BY_DEFAULT = True
# 配置哪些群组里的用户,具有哪个角色
# 例如owner_group 这个群组里的用户具有owner角色
AUTH_LDAP_SENTRY_GROUP_ROLE_MAPPING = {
'owner': ['owner_group'],
'admin': ['admin_group'],
'member': [], # 缺省, 这里就不用配置了
}
# 缺省角色(如果不属于我们配置的群组)
AUTH_LDAP_SENTRY_ORGANIZATION_ROLE_TYPE = 'member'
#
AUTH_LDAP_SENTRY_ORGANIZATION_GLOBAL_ACCESS = True
#
AUTH_LDAP_SENTRY_USERNAME_FIELD = 'cn'

# 启用ldap登录
AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS + (
'sentry_ldap_auth.backend.SentryLdapBackend',
)

# 日志相关
import logging
logger = logging.getLogger('django_auth_ldap')
logger.addHandler(logging.StreamHandler())
logger.setLevel('DEBUG')

AUTH_LDAP_SENTRY_GROUP_ROLE_MAPPING 字典的value值, 只需要写对应群组的RDN即可, 无需填写完整的DN

如果一个用户分别属于多个群组, 那么系统会取登记最高的角色, 角色优先级如下(由低到高)

def _get_effective_sentry_role(group_names):
role_priority_order = [
'member',
'admin',
'manager',
'owner',
]

登录验证

打开http://127.0.0.1:8080 然后分别用admin, king1, king2 登录, 在member页面http://127.0.0.1:8080/settings/sentry/members/ 可以看到三个用户属于不同的角色

参考

  1. https://github.com/Banno/getsentry-ldap-auth
  2. https://django-auth-ldap.readthedocs.io/en/latest/reference.html
  3. https://darkcooking.gitbooks.io/django-auth-ldap/content/chapter4.html

OpenLdap学习

运行

$ docker run -d --privileged -p 10004:80 --name php \
--env PHPLDAPADMIN_HTTPS=false --env PHPLDAPADMIN_LDAP_HOSTS=172.17.0.1 \
--detach osixia/phpldapadmin

172.17.0.1 注意留意下本地docker网卡的ip地址

$ docker run -p 389:389 --name ldap \
--network bridge --hostname openldap-host \
--env LDAP_ORGANISATION="example" --env LDAP_DOMAIN="example.org" \
--env LDAP_ADMIN_PASSWORD="pwd" --detach osixia/openldap

SSL

如果要使用SSL访问, 将地址从ldap://localhost换成ldaps://localhost

若使用自己的证书

$ docker run --hostname ldap.example.org \
--volume /path/to/certificates:/container/service/slapd/assets/certs \
--env LDAP_TLS_CRT_FILENAME=my-ldap.crt \
--env LDAP_TLS_KEY_FILENAME=my-ldap.key \
--env LDAP_TLS_CA_CRT_FILENAME=the-ca.crt \
--detach osixia/openldap

原生客户端

支持WindowsLinux , 个人感觉体验不如phpldapadmin

验证

打开本地浏览器, 访问http://localhost:10004 即可打开PHPLdapAdmin

端口10004和docker保持一致

需要登录, 登录帐号密码cn=admin,dc=example,dc=org / pwd

注意参数和docker中的保持一致, 缺省用户是固定的admin

命令行

也可以登录docker shell用命令行测试

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bfa797f2e0c7 osixia/phpldapadmin "/container/tool/run" 15 minutes ago Up 15 minutes 443/tcp, 0.0.0.0:10004->80/tcp php
e0c9ca20a8f1 osixia/openldap "/container/tool/run" 15 minutes ago Up 15 minutes 0.0.0.0:389->389/tcp, 636/tcp ldap
$ docker exec -it --privileged ldap /bin/bash
$ ldapsearch -x -H ldap://localhost -D "cn=admin,dc=example,dc=org" -w pwd -b "dc=example,dc=org"
# extended LDIF
#
# LDAPv3
# base <dc=example,dc=org> with scope subtree
# filter: (objectclass=*)
# requesting: ALL
#

# example.org
dn: dc=example,dc=org
objectClass: top
objectClass: dcObject
objectClass: organization
o: example
dc: example

# admin, example.org
dn: cn=admin,dc=example,dc=org
objectClass: simpleSecurityObject
objectClass: organizationalRole
cn: admin
description: LDAP administrator
userPassword:: e1NTSEF9cVNPeHRiYTFoQ0RtMlJPcWRwOW0vaTVULzRidzE2NHI=

# search result
search: 2
result: 0 Success

# numResponses: 3
# numEntries: 2

概念

ldap的数据是分层的数组结构, 并且可能有多个DB,每个DB一棵树

dc:         com
|
dc: genfic ## (公司)
/ \
ou: People servers ## (公司部门)
/ \ ..
uid: .. John ## (部门里的数据)

一些常用的概念

  • Distinguished Name(DN) 惟一辨别名,类似于Linux文件系统中的绝对路径,每个对象都有一个惟一的名称,如”uid=king,ou=qiu,dc=example,dc=org”,在一个目录树中DN总是惟一的
  • RDN, DN最左边的一节, 一直凭借父节点的RDN就构成了DN
  • OID, 可以理解为内部ID, 但是仍然需要申请并公示, 避免重复
  • Domain Component(DC) 域名的部分,其格式是将完整的域名分成几部分,如域名为example.org变成dc=example,dc=org
  • User Id(uid) 用户ID,如”king”
  • Organization Unit(OU) 组织单位,类似于Linux文件系统中的子目录,它是一个容器对象,组织单位可以包含其他各种对象(包括其他组织单元),如”lixin”
  • Common Name(CN) 公共名称,如”king qiu”
  • Surname(SN) 姓,如”king”
  • Relative dn(RDN) 相对辨别名,类似于文件系统中的相对路径,它是与目录树结构无关的部分,如”uid=qiu”或”cn=king”
  • Country(C) 国家,如”CN”或”US”等。
  • Organization(O) 组织名,如”Example, Inc.”

通信协议/渠道

以下内容以具体实现slapd 为准

slapd提供了几种方式客户端接入连接渠道

URL Protocol Transport
ldap:/// LDAP TCP port 389
ldaps:/// LDAP over SSL TCP port 636
ldapi:/// LDAP IPC (Unix-domain socket)

当使用官方的客户端工具时, 使用参数-H

后端(Backends)

slapd 支持多种数据的后端存储方式

  • Berkeley DB
  • LDAP, not an actual database; instead it acts as a proxy to forward incoming requests to another LDAP server
  • LDIF, a basic storage backend that stores entries in text files in LDIF format
  • LMDB, the recommended primary backend for a normal slapd database. It uses OpenLDAP’s own Lightning Memory-Mapped Database (LMDB) library to store data and is intended to replace the Berkeley DB backends
  • etc…
# 从cn=config下过滤特定objectClass所有条目的dn
$ ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b cn=config \
-s sub "(objectClass=olcDatabaseConfig)" dn
dn: olcDatabase={-1}frontend,cn=config
dn: olcDatabase={0}config,cn=config
dn: olcDatabase={1}mdb,cn=config

可以看到, 上述实例使用的mdb

数据库(Database)

早期的slapd使用配置文件slapd.conf, 目前已不建议, 考虑dynamic runtime configuration engine , 后者有以下优势

  • is fully LDAP-enabled
  • is managed using the standard LDAP operations
  • stores its configuration data in an LDIF database, generally in the /usr/local/etc/openldap/slapd.d directory.
  • allows all of slapd’s configuration options to be changed on the fly, generally without requiring a server restart for the changes to take effect.

从上面的环境可以看到, 缺省就存在三个database

  • frontend, 对所有DB有效的配置, 优先级较低(special database that is used to hold database-level options that should be applied to all the other databases)
  • config, 配置数据库
  • mdb(视bachend而不同), 实际的数据存储

实际上slapd可以有多个db, slapd会根据dn匹配各个库的olcSuffix来做路由

$ ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b "olcDatabase={1}mdb,cn=config" \
-s sub "(objectClass=olcDatabaseConfig)" olcSuffix
dn: olcDatabase={1}mdb,cn=config
olcSuffix: dc=example,dc=org

官方提供了

config库

config库的根DN是cn=config, 这里包含了所有的配置

具体的每个字段的意义, 参考https://www.openldap.org/doc/admin24/slapdconf2.html

$ ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b "cn=config" dn 2>/dev/null | head -n 5
dn: cn=config
dn: cn=module{0},cn=config
dn: cn=module{1},cn=config

数据库配置

几个重要的配置

对于olcSuffix, 更具体的路由应该在前, 否则后面的db永远无法路由

添加/删除/配置

$ cat > base.ldif  << EOF
> dn: ou=lixin,dc=example,dc=org
objectClass: organizationalUnit
ou: lixin
# 空行
dn: ou=tech,ou=lixin,dc=example,dc=org
objectClass: organizationalUnit
ou: tech
description: 产品部
# 空行
dn: cn=king1,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: king1
userPassword: 1
# 空行
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: king2
userPassword: 1
> EOF
#
$ ldapadd -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org -f base.ldif
adding new entry "ou=lixin,dc=example,dc=org"
adding new entry "ou=tech,ou=lixin,dc=example,dc=org"
adding new entry "cn=king1,ou=tech,ou=lixin,dc=example,dc=org"
adding new entry "cn=king2,ou=tech,ou=lixin,dc=example,dc=org"

查询

$ ldapsearch -x -H ldap://localhost  -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" dn
# lixin, example.org
dn: ou=lixin,dc=example,dc=org

# tech, lixin, example.org
dn: ou=tech,ou=lixin,dc=example,dc=org

# king1, tech, lixin, example.org
dn: cn=king1,ou=tech,ou=lixin,dc=example,dc=org

# king2, tech, lixin, example.org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org

# search result
search: 2
result: 0 Success

# numResponses: 5
# numEntries: 4

修改

$ cat > c << EOF
> dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
changetype: modify
add: description
description: 你懂的
> EOF
#
$ ldapmodify -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org -f c
modifying entry "cn=king2,ou=tech,ou=lixin,dc=example,dc=org"
#
$ cat > c << EOF
> dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
changetype: modify
delete: description
description: 你懂的
-
add: street
street: iii
> EOF""
$ ldapmodify -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org -f c
modifying entry "cn=king2,ou=tech,ou=lixin,dc=example,dc=org"

多字段属性增删改

$ ldapsearch -x -H ldap://localhost  -w pwd -D cn=admin,dc=example,dc=org \
-b "cn=king2,ou=tech,ou=lixin,dc=example,dc=org" street | \
sed -e "/^$/d" -e "/^#/d"
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
street: iii
street: jjj
street: kkk
# 删除stree=jjj的条目, 修改street=kkk的条目修改为lll
$ cat > c << EOF
> dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
changetype: modify
delete: street
street: jjj
-
delete: street
street: kkk
-
add: street
street: lll
> EOF
#
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "cn=king2,ou=tech,ou=lixin,dc=example,dc=org" street | \
sed -e "/^$/d" -e "/^#/d"
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
street: iii
street: lll

删除

$ cat > base.ldif << EOF
> dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: admin
userPassword: 1
> EOF
#
$ ldapadd -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org -f base.ldif
adding new entry "cn=admin,ou=tech,ou=lixin,dc=example,dc=org"
#
$ ldapdelete -H ldap://localhost -D cn=admin,dc=example,dc=org -w 1 \
cn=king1,ou=tech,ou=lixin,dc=example,dc=org
#
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" dn
# lixin, example.org
dn: ou=lixin,dc=example,dc=org

# tech, lixin, example.org
dn: ou=tech,ou=lixin,dc=example,dc=org

# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org

# king2, tech, lixin, example.org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org

# search result
search: 2
result: 0 Success

查询/filter

ldap的优势是快速/灵活的查询, ldapsearch有一个filter参数, 用于定制查询条件

几个概念

  • basedn 从树的那个子(根)节点开始查找(-b)
  • filter 查询条件
  • attrs 查询结果返回哪些属性

filter手册,参考https://www.zytrax.com/books/ldap/apa/search.html

# 从`ou=lixin,dc=example,dc=org`开始查找
# 匹配属性cn等于`king2`的条目
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" \
cn=king2 | sed -e "/^$/d" -e "/^#/d"
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
objectClass: top
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: king2
userPassword:: e1NTSEF9RzFqOGszdG82aHRWamRvQmYvRUlqbmo1dHNpb0NQcTE=
street: iii
description:: 5L2g5oeC55qE
# 接上, 只显示dn属性
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" \
cn=king2 dn | sed -e "/^$/d" -e "/^#/d"
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org

判断

  • = 等于
  • ~= 不等于
  • >= 大于等于
  • <= 小于等于

比较逻辑支持通配符*

与或非

  • & (AND)
  • ! (NOT)
  • | (OR)

优先级用括号(界定

(&(exp1)(exp2)(exp3)) # exp1 AND exp2 AND exp3
(|(exp1)(exp2)(exp3)) # exp1 OR exp2 OR exp3
(!(exp1)) # NOT exp1
(&(!(exp1))(!(exp2))) # NOT exp1 AND NOT exp2
# cn等于admin 或者 street等于iii
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" \
"(|(cn=admin)(street=iii))" dn | sed -e "/^$/d" -e "/^#/d"
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org

DN查找

用于查找DN中的每一段是否包含某个属性, 不支持通配符

# dn中包含ou等于tech的条目
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" "ou:dn:=tech" dn | sed -e "/^$/d" -e "/^#/d"
dn: ou=tech,ou=lixin,dc=example,dc=org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
# dn中包含cn等于admin的条目
$ ldapsearch -x -H ldap://localhost -w pwd -D cn=admin,dc=example,dc=org \
-b "ou=lixin,dc=example,dc=org" "cn:dn:=admin" dn | sed -e "/^$/d" -e "/^#/d"
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org

匹配规则(matchingrules)

每个属性的业务场景各有不同, 所以ldap引入了匹配规则, 用于约束属性的匹配规则, 例如姓名大小写不一致关系并不大

matchingRule ( 2.5.13.2 NAME 'caseIgnoreMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )

ldap内建了一大波规则

# Subschema
dn: cn=Subschema
matchingRules: ( 2.5.13.0 NAME 'objectIdentifierMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
matchingRules: ( 2.5.13.1 NAME 'distinguishedNameMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )
matchingRules: ( 2.5.13.2 NAME 'caseIgnoreMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
matchingRules: ( 2.5.13.3 NAME 'caseIgnoreOrderingMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
matchingRules: ( 2.5.13.4 NAME 'caseIgnoreSubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )
matchingRules: ( 2.5.13.5 NAME 'caseExactMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
matchingRules: ( 2.5.13.6 NAME 'caseExactOrderingMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
matchingRules: ( 2.5.13.7 NAME 'caseExactSubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )
matchingRules: ( 2.5.13.8 NAME 'numericStringMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.36 )
matchingRules: ( 2.5.13.10 NAME 'numericStringSubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )
matchingRules: ( 2.5.13.13 NAME 'booleanMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 )
matchingRules: ( 2.5.13.14 NAME 'integerMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )
matchingRules: ( 2.5.13.15 NAME 'integerOrderingMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )
matchingRules: ( 2.5.13.16 NAME 'bitStringMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.6 )
matchingRules: ( 2.5.13.17 NAME 'octetStringMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )
matchingRules: ( 2.5.13.18 NAME 'octetStringOrderingMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )
matchingRules: ( 2.5.13.20 NAME 'telephoneNumberMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.50 )
matchingRules: ( 2.5.13.21 NAME 'telephoneNumberSubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.58 )
matchingRules: ( 2.5.13.23 NAME 'uniqueMemberMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )
matchingRules: ( 2.5.13.27 NAME 'generalizedTimeMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )
matchingRules: ( 2.5.13.28 NAME 'generalizedTimeOrderingMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )
matchingRules: ( 2.5.13.29 NAME 'integerFirstComponentMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )
matchingRules: ( 2.5.13.30 NAME 'objectIdentifierFirstComponentMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
matchingRules: ( 2.5.13.34 NAME 'certificateExactMatch'
SYNTAX 1.2.826.0.1.3344810.7.1 )
matchingRules: ( 1.3.6.1.4.1.1466.109.114.1 NAME 'caseExactIA5Match'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
matchingRules: ( 1.3.6.1.4.1.1466.109.114.2 NAME 'caseIgnoreIA5Match'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
matchingRules: ( 1.3.6.1.4.1.1466.109.114.3 NAME 'caseIgnoreIA5SubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
matchingRules: ( 1.3.6.1.4.1.4203.1.2.1 NAME 'caseExactIA5SubstringsMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
matchingRules: ( 1.2.840.113556.1.4.803 NAME 'integerBitAndMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )
matchingRules: ( 1.2.840.113556.1.4.804 NAME 'integerBitOrMatch'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )

在比较时, 可以后面加一个oid 修改缺省的匹配规则

# default sn EQUALITY comparison behaviour
# is caseIgnoreMatch (2.5.13.2)
sn=smith

# override EQUALITY match with case sensitive match
sn:caseExactMatch:=Smith
# functionally same as above using OID
sn:2.5.13.5:=Smith

# if a wildcard appears in seach the SUBSTR
# MatchingRule applies

# default sn SUBSTR comparison behavior
# is caseIgnoreSubstringMatch
sn=*s* # finds Smith or smith

# override SUBSTR match with case sensitive match
sn:caseExactSubstringMatch:=*S* # only finds Smith
# functionally same as above using OID
sn:2.5.13.7:=*S*

权限

到目前位置, 所有的操作,要不使用unix socket通道(ldapi:///), 不受权限控制, 要不就使用超级管理员admin,dc=example,dc=org

slapd提供了完善的权限控制, 让其他用户也能做全部/部分操作

缺省情况下, 其他用户只有对自己的权限

$ ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b "olcDatabase={1}mdb,cn=config" \
-s sub "(objectClass=olcDatabaseConfig)" olcAccess
dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to attrs=userPassword,shadowLastChange by self write by dn="cn=a
dmin,dc=example,dc=org" write by anonymous auth by * none
olcAccess: {1}to * by self read by dn="cn=admin,dc=example,dc=org" write by *
none
# 查询lixin部门
$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 1 -b "ou=lixin,dc=example,dc=org" dn
search: 2
result: 32 No such object
# 查询自己
$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 1 -b "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" dn
# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org

search: 2
result: 0 Success

现在给cn=admin,ou=tech,ou=lixin,dc=example,dc=org 加一个read权限

$ cat > perm << EOF
> dn: olcDatabase={1}mdb,cn=config
changetype: modify
add: olcAccess
olcAccess: {0}to dn.subtree="ou=lixin,dc=example,dc=org"
by dn.exact="cn=admin,ou=tech,ou=lixin,dc=example,dc=org" read
by anonymous auth
by * none
> EOF
$ ldapmodify -H ldapi:// -Y EXTERNAL -f perm
#
$ ldapsearch -LLL -Y EXTERNAL -H ldapi:/// -b "olcDatabase={1}mdb,cn=config" \
-s sub "(objectClass=olcDatabaseConfig)" olcAccess
dn: olcDatabase={1}mdb,cn=config
olcAccess: {0}to dn.subtree="ou=lixin,dc=example,dc=org" by dn.exact="cn=adm
in,ou=tech,ou=lixin,dc=example,dc=org" read by anonymous auth by * none
olcAccess: {1}to attrs=userPassword,shadowLastChange by self write by dn="cn=a
dmin,dc=example,dc=org" write by anonymous auth by * none
olcAccess: {2}to * by self read by dn="cn=admin,dc=example,dc=org" write by *
none

$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 1 -b "ou=lixin,dc=example,dc=org" dn
# lixin, example.org
dn: ou=lixin,dc=example,dc=org

# tech, lixin, example.org
dn: ou=tech,ou=lixin,dc=example,dc=org

# hr, tech, lixin, example.org
dn: cn=hr,ou=tech,ou=lixin,dc=example,dc=org

# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org

# king1, tech, lixin, example.org
dn: cn=king1,ou=tech,ou=lixin,dc=example,dc=org

# king2, tech, lixin, example.org
dn: cn=king2,ou=tech,ou=lixin,dc=example,dc=org

search: 2
result: 0 Success
# 查分配的权限之外的dn就找不到了
$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 1 -b "dc=example,dc=org" dn
search: 2
result: 32 No such object

权限Index

由于权限规则定义的顺序非常关键, 所以slapd使用一个{X}来锁定顺序, 在上面的例子中,可以看到{0}, {1}, {2}

我们的配置中手动写死{0}, 是为了确保新插入的规则优先级最高, 因为此调规则的DN范围是小于后两条

{X}的另外一个作用是在增删改查时用作定位, 例如删除第一条

dn: olcDatabase={1}mdb,cn=config
changetype: modify
delete: olcAccess
olcAccess: {0}

权限集合

Level Privileges Description
none 0 no access
disclose d needed for information disclosure on error
auth dx needed to authenticate (bind)
compare cdx needed to compare
search scdx needed to apply search filters
read rscdx needed to read search results
write wrscdx needed to modify/rename
manage mwrscdx needed to manage
  • 高级别权限自动包含低级别权限
  • search和read的区别在于, search可以返回查到了, 但是没有查到的具体结果
  • 认证权限, 表示允许登录, 至于能不能成功, 则使用帐号密码认证

权限规则

规则语法

access to what: 
by who access control

  • control 就是之前的权限等级, 读/写/授权etc
  • 指令中包含1个to语句,可多个by语句
  • what 限定规则应用条目(entries)的集合, 全部用*
  • who 授权给哪些实体(entities)

what

匹配所有使用*
多种匹配规则可同时使用

scope(基于某个根DN匹配)

  • base matches only the entry with provided DN
  • one matches the entries whose parent is the provided DN
  • subtree matches all entries in the subtree whose root is the provided DN
  • children matches all entries under the DN (but not the entry named by the DN)
0: o=suffix
1: cn=Manager,o=suffix
2: ou=people,o=suffix
3: uid=kdz,ou=people,o=suffix
4: cn=addresses,uid=kdz,ou=people,o=suffix
5: uid=hyc,ou=people,o=suffix
  • dn.base=”ou=people,o=suffix” match 2;
  • dn.one=”ou=people,o=suffix” match 3, and 5;
  • dn.subtree=”ou=people,o=suffix” match 2, 3, 4, and 5; and
  • dn.children=”ou=people,o=suffix” match 3, 4, and 5.

regex(基于DN正则表达式匹配)

access to dn.regex="(.+,)?ou=People,(dc=[^,]+,dc=[^,]+)$"

filter(基于过滤器)

filter=(objectClass=person)
# 配合使用
dn.one="ou=people,o=suffix" filter=(objectClass=person)

attrs(只授权部分属性)

缺省授权所有的attr, 这里可以过滤限定属性的范围

to attrs=member,entry

有两个伪属性(pseudo), entry children , 需要特别注意

  • To read (and hence return) a target entry, the subject must have read access to the target’s entry attribute
  • To perform a search, the subject must have search access to the search base’s entry attribute.
  • To add or delete an entry, the subject must have write access to the entry’s entry attribute AND must have write access to the entry’s parent’s children attribute
  • To rename an entry, the subject must have write access to entry’s entry attribute AND have write access to both the old parent’s and new parent’s children attributes.

who

用于限定权限赋予给哪些实体, 一些特殊的实体如下

Specifier Entities
* All, including anonymous and authenticated users
anonymous Anonymous (non-authenticated) users
users Authenticated users
self User associated with target entry

剩下两种通用的匹配方式 scope regex 和what用于保持一致

一个特殊的dnattr用于限定DN在某种条目的attr列表中

这个attr只能存放其他条目的DN

权限评估(Evaluation)

当判断who是否具有what的access权限时

  • 根据权限结合的优先级, 从先到后
  • 找到第一条匹配what的记录(dn/attr)
  • 根据这条记录的by, 判断当前用户是否符合who
    • 如果没有who, 那么会append一个缺省的access to * by * none (而不是继续评估下个access to)
    • 如果有, 则检查by的access 是否满足客户端申请的权限
  • 如果没有what, 那么then a default read is used

所以, 一个access to必须把所有by的考虑清楚, 否则未考虑的who将无权限

密码

新建用户时, 可以用明文写到userPassword 一次性创建, 通过ldapsearch可以看到密码被加密了, 不过只是简单的base64编码, 安全性较低

# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
userPassword:: MQ==

我们可以用slappasswd 生成带盐的强加密密码

再测试之前, 先调整权限, 让所有用户可以自己修改密码

# 内建规则, 所有用户可以自己修改密码
olcAccess: {0}to attrs=userPassword,shadowLastChange
by self write
by dn="cn=admin,dc=example,dc=org" write
by anonymous auth
by * none
# 本地新增,移动到第二位
olcAccess: {1}to dn.subtree="ou=lixin,dc=example,dc=org" by dn.exact="cn=adm
in,ou=tech,ou=lixin,dc=example,dc=org" read by anonymous auth by * none
# 超级管理员
olcAccess: {2}to * by self read by dn="cn=admin,dc=example,dc=org" write by *
none

$ slappasswd -h {SSHA}
New password:
Re-enter new password:
{SSHA}zDDMqF0N2uSGQfsFlYIYJVehKpVlCqhi
#
$ cat > pwd << EOF
> dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
changetype: modify
replace: userPassword
userPassword: {SSHA}zDDMqF0N2uSGQfsFlYIYJVehKpVlCqhi
> EOF
#
$ ldapmodify -x -H ldap://localhost -w pwd \
-D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" -f pwd
modifying entry "cn=admin,ou=tech,ou=lixin,dc=example,dc=org"
#
$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w pwd -b "ou=lixin,dc=example,dc=org" userPassword
ldap_bind: Invalid credentials (49)
#
$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 123456 -b "ou=lixin,dc=example,dc=org" userPassword
# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
userPassword:: e1NTSEF9ekRETXFGME4ydVNHUWZzRmxZSVlKVmVoS3BWbENxaGk=
#
$ echo e1NTSEF9ekRETXFGME4ydVNHUWZzRmxZSVlKVmVoS3BWbENxaGk= | base64 -d
{SSHA}zDDMqF0N2uSGQfsFlYIYJVehKpVlCqhi

第三方授权

ldap一个很重要的用途就是做第三方的认证授权

通常的做法

  • 客户输入一个cn或者uid, 按照限定的规则拼装成DN
  • 根据限定的搜索条件, 查询DN是否存在
  • 使用DN和客户输入的密码做bind

Group

slapd内建了一个groupOfNames的objectClass, 其包含一个member的属性, 该属性只能包含其他DN

# ou, lixin, example.org
dn: cn=ou,ou=lixin,dc=example,dc=org
member: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
member: cn=king2,ou=tech,ou=lixin,dc=example,dc=org
objectClass: groupOfNames
objectClass: top
cn: ou

那么可以考虑将角色和groupOfNames关联起来, 如果某个用户在这个groupOfNames的member中,那么就具有这个角色

这里有个问题, 在user端, 没有渠道查到它属于哪些groups, 为此需要安装额外的插件

memberOf

具体安装参考https://mayanbin.com/post/enable-memberof-in-openldap.html

$ cat > a << EOF
> dn: cn=module,cn=config
cn: module
objectClass: olcModuleList
olcModulePath: /usr/lib64/openldap
# 空行
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof.la
> EOF
#
$ ldapadd -Q -Y EXTERNAL -H ldapi:/// -f a
adding new entry "cn=module,cn=config"

modifying entry "cn=module{0},cn=config"
#
$ cat > a << EOF
> dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfDangling: ignore
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
> EOF
#
$ ldapadd -Q -Y EXTERNAL -H ldapi:/// -f a
adding new entry "olcOverlay=memberof,olcDatabase={1}mdb,cn=config"

之后就有了memberOf 字段

$ ldapsearch -x -H ldap://localhost -D "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" \
-w 123456 -b "cn=admin,ou=tech,ou=lixin,dc=example,dc=org" memberof
# admin, tech, lixin, example.org
dn: cn=admin,ou=tech,ou=lixin,dc=example,dc=org
memberOf: cn=ou,ou=lixin,dc=example,dc=org
memberOf: cn=ou2,ou=lixin,dc=example,dc=or

注意:

  • memberof不属于强制,ldapsearch查询需要单独指定
  • 老数据不会修复

当我们做组判断时,

  • 根据三方系统配置的组名, 按照规则拼接完整DN,
  • 根据搜索规则, 判断DN是否存在
  • 检查用户的memberOf里面是否存在这个组DN

Python

$ pip install flask_simpleldap

如果不使用flask, 也可以直接安装python-ldap

Debian/Ubuntu:

$ sudo apt-get install libsasl2-dev python-dev libldap2-dev libssl-dev

RedHat/CentOS:

$ sudo yum install python-devel openldap-devel
import ldap
ldapconn = ldap.initialize('ldap://localhost:389')

def p(con, u, p):
res = con.simple_bind_s(u, p)
print(res)

res = con.whoami_s()
print(res)

res = con.search_s(
"cn=admin,dc=example,dc=org",
ldap.SCOPE_SUBTREE,
# ldap.SCOPE_BASE,
# ldap.SCOPE_ONELEVEL,
'(&(cn=admin))',
)
print(res)

p(ldapconn, 'cn=admin,dc=example,dc=org', "pwd")

新建用户, 注意uid, objectClass

# king, example.org
dn: uid=king,dc=example,dc=org
uid: king
userPassword:: e01ENX14TXBDT0tDNUk0SU56RkNhYjNXRW13PT0=
objectClass: account
objectClass: simpleSecurityObject
objectClass: top

from flask import Flask, g
from flask_simpleldap import LDAP

app = Flask(__name__)
app.secret_key = 'dev key'
app.debug = True

app.config['LDAP_OPENLDAP'] = True
app.config['LDAP_BASE_DN'] = 'dc=example,dc=org'
app.config['LDAP_USERNAME'] = 'cn=admin,dc=example,dc=org'
app.config['LDAP_PASSWORD'] = 'pwd'
app.config['LDAP_USER_OBJECT_FILTER'] = '(&(objectclass=simpleSecurityObject)(userid=%s))'

ldap = LDAP(app)

@app.route('/')
@ldap.basic_auth_required
def index():
return 'Welcome, {0}!'.format(g.ldap_username)

if __name__ == '__main__':
app.run(port=5010)

浏览器访问http://127.0.0.1:5010/, 即弹出对话框,输入king, 1 登录成功

具体原理比较简单

  • 先使用环境变量配置的帐号密码(管理员)登录
    • LDAP_USERNAME/LDAP_PASSWORD
    • 登录见simple_bind_s
  • 根据输入的uid以及LDAP_USER_OBJECT_FILTER来搜索用户
  • 找到用户, 获取完整的DN
  • 使用DN和输入的密码再登录, 确认密码有效性

Golang

https://github.com/go-ldap/ldap/blob/master/examples_test.go 有较详细的sample

如果gopkg.in/asn1-ber.v1 安装不上, 修改go.mod, 添加以下内容, 重新tidy

replace (
gopkg.in/asn1-ber.v1 => github.com/go-asn1-ber/asn1-ber v0.0.0-20181015200546-f715ec2f112d
)
package main

import (
"fmt"

"github.com/go-ldap/ldap"
)

func main() {
conn, err := ldap.Dial("tcp", "127.0.0.1:389")
if err != nil {
panic(err)
}
defer conn.Close()

if err := conn.Bind("cn=admin,dc=example,dc=org", "pwd"); err != nil {
panic(err)
}

searchRequest := ldap.NewSearchRequest(
"dc=example,dc=org", // The base dn to search
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(&(uid=king))", // The filter to apply
[]string{}, // A list attributes to retrieve
nil,
)

sr, err := conn.Search(searchRequest)
if err != nil {
panic(err)
}

for _, entry := range sr.Entries {
fmt.Printf("%s: %v\n", entry.DN, entry.GetAttributeValue("cn"))
}
for _, entry := range sr.Referrals {
fmt.Printf("%s: %v\n", entry)
}
}

数据安全

缺省的mdb数据存储/var/lib/ldap/, 一个数据文件,一个锁文件, 我们可以外挂docker数据目录(/var/lib/ldap)和配置目录(/etc/ldap/slapd.d)实现数据的持久化保存

import lmdb

# 注意传入的路径是一个目录, 下面包含两文件
env_db = lmdb.Environment('/home/user/mdb')

txn = env_db.begin()
for key, value in txn.cursor(): # 遍历
print(key)

print("-")
data = txn.get("cn".encode("utf8"))
env_db.close()

安装问题

$ sudo aptitude install libsasl2-dev
The following NEW packages will be installed:
libsasl2-dev{b}
0 packages upgraded, 1 newly installed, 0 to remove and 5 not upgraded.
Need to get 254 kB of archives. After unpacking 831 kB will be used.
The following packages have unmet dependencies:
libsasl2-dev : Depends: libsasl2-2 (= 2.1.26.dfsg1-14build1) but 2.1.26.dfsg1-14ubuntu0.1 is installed.
The following actions will resolve these dependencies:

Keep the following packages at their current version:
1) libsasl2-dev [Not Installed]



Accept this solution? [Y/n/q/?] n
The following actions will resolve these dependencies:

Downgrade the following packages:
1) libsasl2-2 [2.1.26.dfsg1-14ubuntu0.1 (now) -> 2.1.26.dfsg1-14build1 (xenial)]
2) libsasl2-2:i386 [2.1.26.dfsg1-14ubuntu0.1 (now) -> 2.1.26.dfsg1-14build1 (xenial)]



Accept this solution? [Y/n/q/?] y
The following packages will be DOWNGRADED:
libsasl2-2 libsasl2-2:i386
The following NEW packages will be installed:
libsasl2-dev
0 packages upgraded, 1 newly installed, 2 downgraded, 0 to remove and 5 not upgraded.
Need to get 354 kB of archives. After unpacking 831 kB will be used.
Do you want to continue? [Y/n/?] y
Get: 1 http://mirrors.aliyun.com/ubuntu xenial/main i386 libsasl2-2 i386 2.1.26.dfsg1-14build1 [51.8 kB]
Get: 2 http://mirrors.aliyun.com/ubuntu xenial/main amd64 libsasl2-2 amd64 2.1.26.dfsg1-14build1 [48.7 kB]
Get: 3 http://mirrors.aliyun.com/ubuntu xenial/main amd64 libsasl2-dev amd64 2.1.26.dfsg1-14build1 [254 kB]
Fetched 354 kB in 0s (384 kB/s)
dpkg: warning: downgrading libsasl2-2:i386 from 2.1.26.dfsg1-14ubuntu0.1 to 2.1.26.dfsg1-14build1
(Reading database ... 530388 files and directories currently installed.)
Preparing to unpack .../libsasl2-2_2.1.26.dfsg1-14build1_i386.deb ...
De-configuring libsasl2-2:amd64 (2.1.26.dfsg1-14ubuntu0.1) ...
Unpacking libsasl2-2:i386 (2.1.26.dfsg1-14build1) over (2.1.26.dfsg1-14ubuntu0.1) ...
dpkg: warning: downgrading libsasl2-2:amd64 from 2.1.26.dfsg1-14ubuntu0.1 to 2.1.26.dfsg1-14build1
Preparing to unpack .../libsasl2-2_2.1.26.dfsg1-14build1_amd64.deb ...
Unpacking libsasl2-2:amd64 (2.1.26.dfsg1-14build1) over (2.1.26.dfsg1-14ubuntu0.1) ...
Selecting previously unselected package libsasl2-dev.
Preparing to unpack .../libsasl2-dev_2.1.26.dfsg1-14build1_amd64.deb ...
Unpacking libsasl2-dev (2.1.26.dfsg1-14build1) ...
Processing triggers for libc-bin (2.23-0ubuntu11) ...
Processing triggers for man-db (2.7.5-1) ...
Setting up libsasl2-2:amd64 (2.1.26.dfsg1-14build1) ...
Setting up libsasl2-2:i386 (2.1.26.dfsg1-14build1) ...
Setting up libsasl2-dev (2.1.26.dfsg1-14build1) ...
Processing triggers for libc-bin (2.23-0ubuntu11) ...

参考

  1. Docker安装OpenLDAP及PHPLdapAdmin客户端
  2. 使用OpenLDAP实现集中式认证
  3. openldap介绍和使用

应用支持

关于配置文件的一些想法

当前配置文件可选择的非常多, 从很早的ini、xml到后来的json、yaml, 以及最近讨论得比较多的toml、hcl等

总体而言, 现在越来越倾向于易与人阅读的格式, 例如yaml、toml、hcl用来结构化的符号已经非常少了, 对比此前的xml、json有明显的区别

具体怎么选择, 首先要从业务需求分析, 比如就几个key/value对, 甚至可以考虑用参数传入, 下面从几个方面考虑

Ini

; 稍微复杂一点的单层嵌套结构

[c]
x = c.x
y = c.y

[d]
x = d.x
y = d.y


java properties

#以下为服务器、数据库信息
dbPort = localhost
databaseName = mydb
dbUserName = root
dbPassword = root
#以下为数据库表信息
dbTable = mytable
#以下为服务器信息
ip = 192.168.0.9


json

{
"a": "a",
"b": "b",
"c":{
"x": "c.x",
"y": "c.y"
},
"d":{
"x": "d.x",
"y": "d.y"
},
"e":[
{ "x":"e[0].x", "y":"e[0].y" },
{ "x":"e[1].x", "y":"e[1].y" }
]
}


yaml

b4: NuLL # string
b5: Null # null
c:
x: c.x
y: c.y
d:
x: d.x
y: d.y


toml

a = "a"
b = "b"

c.x = "c.x"
c.y = "c.y"

[d]
x = "d.x"
y = "d.y"

[[e]]
x = "e[0].x"
y = "e[0].y"

[[e]]
x = "e[1].x"
y = "e[1].y"

机器友好&人友好

机器友好,意味着表述能力强, 读写效率高, 如果有频繁的读写操作, 并且很少会需要人来修改, 那么此类较为合适

人友好,则表示容易看懂, 若是修改不易出错.

对于一个复杂 ( 特别是嵌套比较深 ) 的配置, 如果需要人来修改, 并非合适的选项, 做个配置页面会更好

像xml虽然强大, 但是解析/阅读都不易, 新项目用得越来越少了

注释友好

除非完全不和人打交道, 否则注释是配置中非常重要的一块内容

xml/json对注释支持就不太好, yaml/properties/toml支持#注释, 部分还支持行尾注释, 而hcl则支持C语言风格的注释

多行友好

在json中写多行需要手动加换行符, 既麻烦又难以阅读, yaml原生支持

key: |
line1
line2

toml进一步扩展了多行的支持

空格恐惧症

像yaml / toml / python 等通过空格缩进来确认层级的配置/语言, 容易因为多/少空格引发错误, 不过如今的编辑器都非常智能, 此类问题较为容易发现

减少结构化符号带来了空格维护问题, 过多的符号则影响内容的阅读, hcl则做了一个折中, 保留了json的大括号, 但是其他的符号适当的省略了

语法增强

所谓配置文件和编程语言相向而行, 越来越像对方了

数组

ini、java properties等对数组支持不是很好, 后续基本都是标配. 目前yaml/toml都支持整形/浮点/布尔/日期等常用类型

层级简化

name = "Orange"
physical.color = "orange"
physical.shape = "round"
site."google.com" = true
{
"name": "Orange",
"physical": {
"color": "orange",
"shape": "round"
},
"site": {
"google.com": true
}
}

代码复用

对于一些共用的变量, 块状配置, 支持引用, 减少维护成本. 主流的配置格式暂未发现方案

有一些第三方的库可以支持, 例如https://pypi.org/project/jsonref/

错误检查

xml/json 有自己的校验框架, 例如json schema, 但这并不是解释器的功能, 也并非必须, 而新的配置, 可能会做一些语法上的要求, 规避一些常见的坑, 比如toml字典里key不可重复

公司技术栈和语言文化

每个公司都有自己的技术栈, 比如阿里的Java, 那么具体用什么配置,很多时候会参考这个技术栈里面广泛使用的配置方案. scalar就搞了一个HOCON

Golang对象存储接口

package main

import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/s3blob"
)

const (
bucketName = "kimq"
)

type blobStorage struct {
driver string
bucket *blob.Bucket
}

func (u *blobStorage) Upload(ctx context.Context, name string, reader io.Reader) (outputURL string, err error) {
key := path.Join("images/", name)
writer, err := u.bucket.NewWriter(ctx, key, nil)
if err != nil {
return "", fmt.Errorf("new writer error: %v", err)
}

_, err = io.Copy(writer, reader)
if err != nil {
return "", fmt.Errorf("io copy error: %v", err)
}

defer writer.Close()

if err := writer.Close(); err != nil {
return "", fmt.Errorf("writer close error: %v", err)
}

ourl := fmt.Sprintf("%s://%s/%s", u.driver, bucketName, key)
return ourl, nil
}

func init_file_bucket(ctx context.Context, bucket string) (*blobStorage, error) {
dir := filepath.Join("/tmp/1/", bucket)
//dir := filepath.Join(os.TempDir(), bucket)
if err := os.MkdirAll(dir, 0775); err != nil {
return nil, fmt.Errorf("create bucket dir %q error: %v", dir, err)
}

bb, err := fileblob.OpenBucket(dir, nil)
if err != nil {
return nil, fmt.Errorf("open bucket error: %v", err)
}

return &blobStorage{
driver: "file",
bucket: bb,
}, nil
}

func init_jd_bucket(ctx context.Context, bucket string) (*blobStorage, error) {
ak := "Access Key ID"
sk := "Access Key Secret"

sess, err := session.NewSession(&aws.Config{
Endpoint: aws.String("s3.cn-south-1.jdcloud-oss.com"), //Bucket所在Endpoint
Region: aws.String("cn-south-1"), //Bucket所在Region
S3ForcePathStyle: aws.Bool(true),
Credentials: credentials.NewStaticCredentials(ak, sk, ""),
})

if err != nil {
return nil, fmt.Errorf("new session error: %v", err)
}

sb, err := s3blob.OpenBucket(ctx, sess, bucket, nil)
if err != nil {
return nil, fmt.Errorf("open bucket error: %v", err)
}

return &blobStorage{
driver: "jds3",
bucket: sb,
}, nil

}

func main() {
path := "/tmp/workshop.png"
filename := filepath.Base(path)
f, err := os.Open(path)
if err != nil {
panic(err)
}

ctx := context.Background()

// up, err := init_file_bucket(ctx, bucketName)
up, err := init_jd_bucket(ctx, bucketName)
if err != nil {
panic(err)
}

if f, err := up.Upload(ctx, filename, f); err == nil {
fmt.Printf("%s uploader ok %s\n", up.driver, f)
} else {
panic(err)
}
}

参考