diff --git a/index.md b/index.md index def11e3..c310041 100644 --- a/index.md +++ b/index.md @@ -1,2 +1,2 @@ ## dongdigua's blog -todo +[elixir写点对点加密聊天软件](https://dongdigua.github.io/p2p_chat) diff --git a/miku_sheep.jpg b/miku_sheep.jpg new file mode 100644 index 0000000..715ed09 Binary files /dev/null and b/miku_sheep.jpg differ diff --git a/p2p_chat.md b/p2p_chat.md index 80191e8..fefa46a 100644 --- a/p2p_chat.md +++ b/p2p_chat.md @@ -1,44 +1,49 @@ # 讲一讲我前段时间做的一个点对点加密聊天软件 +![miku](https://dongdigua.github.io/miku_sheep.jpg) ## 版权声明 -本文章**仅在**本人github pages发布, -转载给爷**标上源链接**, 敢在某些平台转载投自制的**你\*\**!** +本文章**仅在**本人github pages发布,
+转载给爷**标上源链接**, 敢在某些平台转载投自制的**你\*\**!**
CC BY-NC-SA +## 开头 +项目地址: https://github.com/dongdigua/p2p_chat
+写着玩的小项目, 肯定有很多不足 + ## 使用到的技术 - UDP socket - 进程(这里指beam虚拟机的进程) - GenServer -- ETS键值存储(Erlang Term Storage)(类似redis, 也是在内存中的) +- ETS键值存储(Erlang Term Storage)[ETS教程](https://elixirschool.com/zh-hans/lessons/storage/ets) - escript编译成可执行文件 - rsa非对称加密 ## 大体架构 -整个思路来源都是从这两个视频来的, UDP打洞 -[with netcat](https://www.youtube.com/watch?v=s_-UCmuiYW8)[with python](https://www.youtube.com/watch?v=IbzGL_tjmv4) -我的理解就是通过发送UDP包打开一个端口来让远程电脑能知道你的端口映射到了公网IP的哪个端口, -然后将两个需要发消息的客户端相互告诉对方各自的公网IP以及映射到的端口, 就能实现p2p通信. +整个思路来源都是从这两个视频来的, UDP打洞
+[with netcat](https://www.youtube.com/watch?v=s_-UCmuiYW8) & [with python](https://www.youtube.com/watch?v=IbzGL_tjmv4)
+我的理解就是通过发送UDP包打开一个端口来让远程电脑能知道你的端口映射到了公网IP的哪个端口,
+然后将两个需要发消息的客户端相互告诉对方各自的公网IP以及映射到的端口, 就能实现p2p通信.
-客户端使用GenServer来实现后端接口和网络通信, 在CLI模块处理用户输入调用GenServer. -服务端可以很简单, 就是收到两个IP然后相互发送对方的地址让客户端能够相互通信, -但是为了能够接受多对客户端以及非阻塞等待客户端, 就用ETS存储客户端的信息, -为了让客户端不乱配对, 就需要增加一个注册功能, 也使用ETS实现. +客户端使用GenServer来实现后端接口和网络通信, 在CLI模块处理用户输入调用GenServer.
+ +服务端可以很简单, 就是收到两个IP然后相互发送对方的地址让客户端能够相互通信,
+但是为了能够接受多对客户端以及非阻塞等待客户端, 就用ETS存储客户端的信息,
+为了让客户端不乱配对, 就需要增加一个注册功能, 也使用ETS实现.
## 客户端实现 内容比较多, 所以我不会讲的很全, 代码不会都放出来 项目目录大概是这样 ```sh ├── client -│   ├── lib -│   │   ├── client -│   │   │   ├── cli.ex # 和用户交互, 调用GenServer后端, escript入口点 -│   │   │   ├── connect.ex # 处理与服务器发送和接受的二进制字符串 -│   │   │   ├── crypto.ex # rsa加密解密 -│   │   │   └── register.ex # 仅生成注册时需要发送的二进制字符串 -│   │   └── client.ex # 客户端核心程序, 包含GenServer和socket通信 -│   ├── mix.exs -│   └── mix.lock +│ ├── lib +│ │ ├── client +│ │ │ ├── cli.ex # 和用户交互, 调用GenServer后端, escript入口点 +│ │ │ ├── connect.ex # 处理与服务器发送和接受的二进制字符串 +│ │ │ ├── crypto.ex # rsa加密解密 +│ │ │ └── register.ex # 仅生成注册时需要发送的二进制字符串 +│ │ └── client.ex # 客户端核心程序, 包含GenServer和socket通信 +│ ├── mix.exs +│ └── mix.lock ``` - 注意这里只在核心程序处理socket, cli模块处理用户交互, 使项目分层化 ### escript @@ -76,6 +81,81 @@ end main\_module指定了程序的入口点main函数, extra\_applications加入erlang库:crypto因为后续需要使用加密 ### GenServer和socket +先是定义了两个结构体, 一个用于存储peer的信息, 一个存储客户端的信息(peer键是peer结构体)
+然后是一堆常量, 服务器可以改成你的128核心1TB内存1EB固态硬盘的小型服务器的地址, key\_integer是客户端的密钥生成器用的
+其实可以在config.exs或者用json来配置, 但是我懒哈哈 +```elixir +defmodule Client do + defmodule Peer do + defstruct [:name, :addr, :port, :pub_key] + end + @serveraddr {127, 0, 0, 1} + @serverpt 1234 + @key_integer <<3>> #should be a valid rsa key_integer + use GenServer + alias Client.Conn + defstruct [:socket, :name, :priv_key, peer: nil] +``` + +GenServer初始化, UDP打开一个端口(从命令行参数传进来) +```elixir + def start_link(port) do + {:ok, socket} = :gen_udp.open(port, [:binary, active: false]) + GenServer.start_link(Client, %Client{socket: socket}, name: :client) + end +``` + +然后是GenServer的回调函数, 实现了必要的接口 +```elixir + def init(%Client{} = client), do: {:ok, client} + + def handle_call({:register, sesstoken, passwd}, _from, client) do + :ok = :gen_udp.send(client.socket, @serveraddr, @serverpt, Client.Reg.register(sesstoken, passwd)) + {:reply, :gen_udp.recv(client.socket, 0), client} + end + + def handle_call({:find, name, sesstoken, passwd}, _from, client) do + :ok = :gen_udp.send(client.socket, @serveraddr, @serverpt, Conn.find_peer(name, sesstoken, passwd)) + case :gen_udp.recv(client.socket, 0) |> Conn.parse_peer() do + {:ok, peer} -> + {:reply, peer, %{client | name: name, peer: peer}} + {:error, reason} -> + {:reply, reason, client} + end + end + + def handle_call(:key, _from, client) do + {pub, priv} = Client.Crypto.generate_key(@key_integer) + :ok = :gen_udp.send(client.socket, client.peer.addr, client.peer.port, hd(tl(pub))) + {:ok, {_addr, _port, peer_pub}} = :gen_udp.recv(client.socket, 0) + full_peer_pub = [@key_integer, peer_pub] + {:reply, full_peer_pub, + %{client | priv_key: priv, peer: %{client.peer | pub_key: full_peer_pub}} + } + end + + def handle_call({:chat, text}, _from, client) do + encrypted = Client.Crypto.encrypt(text, client.peer.pub_key) + :ok = :gen_udp.send(client.socket, client.peer.addr, client.peer.port, encrypted) + {:reply, encrypted, client} + end + + def handle_cast(:recv, client) do + spawn(fn -> recv_loop(client.socket, client.priv_key) end) + {:noreply, client} + end +``` + +最后是接收消息(和解密) +```elixir + defp recv_loop(socket, priv_key) do + {:ok, {_ip, _port, data}} = :gen_udp.recv(socket, 0) + decrypted = Client.Crypto.decrypt(data, priv_key) + IO.puts(IO.ANSI.clear_line() <> "\r" <> IO.ANSI.cyan() <> "received: #{inspect(decrypted)}" <> IO.ANSI.reset()) + recv_loop(socket, priv_key) + end +end +``` ### 用户交互CLI 首先使用OptionParser解析命令行参数, 如果解析成功就启动GenServer @@ -92,56 +172,163 @@ def main(args \\ []) do end end ``` -然后main\_cli()就处理用户的输入, 没什么好讲的. -这里主要说一下输入密码的部分: +然后main\_cli()就处理用户的输入,
+然后先向服务器发起find peer请求(需要身份验证), 找到peer之后交换密钥然后就可以发消息了
+这里主要说一下输入密码的部分:
erlang的:io.get_password()函数在mix中不管用, 所以就需要自己写一个清空用户输入的小东西 ```elixir def gets_passwd(prompt) do - pid = spawn(fn -> clear_input(prompt) end) - value = IO.gets("") - send(pid, :stop) - value + pid = spawn(fn -> clear_input(prompt) end) + value = IO.gets("") + send(pid, :stop) + value end def clear_input(prompt) do - IO.write(IO.ANSI.clear_line() <> "\r" <> prompt) #\r用于回到行首 - :timer.sleep(10) - receive do - :stop -> IO.write("\r") - after - 0 -> clear_input(prompt) - end + IO.write(IO.ANSI.clear_line() <> "\r" <> prompt) #\r用于回到行首 + :timer.sleep(10) + receive do + :stop -> IO.write("\r") + after + 0 -> clear_input(prompt) + end end ``` + + ## 服务端实现 +服务端仅作为暴露客户端连接和将两个客户端牵手的作用, 当然为了区分还要有注册功能
+项目目录大概是这样 +```sh +└── server + ├── Dockerfile + ├── lib + │   ├── server + │   │   ├── application.ex + │   │   ├── connection.ex + │   │   └── register.ex + │   └── server.ex + └── mix.exs +``` +这里用docker方便部署 +### 应用程序监视器 +因为是服务端嘛, 鬼知道用户或其它东西会整出什么么蛾子, 所以使用应用程序监视器在程序挂掉时重启进程很有必要
+这也是erlang/OTP的let it crash哲学的一种体现[我做的"crash辅导"视频](https://www.bilibili.com/video/BV193411A7fa) +```elixir +defmodule Server.Application do + use Application + @impl true + def start(_type, _args) do + children = [ + %{ + id: Server, + start: {Server, :start, []} + }] + opts = [strategy: :one_for_one, name: Server.Supervisor] + Supervisor.start_link(children, opts) + end +end +``` ### socket -### 存储 +首先还是定义一个结构体用于存储每个用户的数据
+启动两个数据库, 打开UDP端口 +```elixir +defmodule Server do + defmodule UserData do + defstruct [:addr, :port, :name, :sesstoken, :passwd] + end + import Server.Conn + @serverpt 1234 + + def start do + Server.Reg.new() + Server.Conn.table_new() + {:ok, socket} = :gen_udp.open(@serverpt, [:binary, active: false]) + serve(socket) + end +``` + +根据收到的消息头部的不同选择处理不同的内容(其实这里的区分判断应该写全, 但是我懒, 能用就行呗)
+然后递归调用自己形成循环(elixir有尾递归优化, 所以这样递归不会有性能问题) +```elixir + def serve(socket) do + case :gen_udp.recv(socket, 0) |> IO.inspect() do + {:ok, {_ip, _port, <>} = data} -> + #FROM:foo;SESSTOKEN:test;PASSWD:hash + spawn(fn -> handle_connection(socket, data) end) + {:ok, {_ip, _port, <>} = data} -> + #REGISTER:token:passwd + spawn(fn -> handle_register(socket, data) end) + {:error, error} -> + IO.inspect(error) + _ -> + nil + end + serve(socket) + end + + def handle_connection(socket, {ip, port, bin}) do + [_, name, sesstoken, passwd] = Regex.run(~r/FROM:(\w+);SESSTOKEN:(\w+);PASSWD:(\w+)/, bin) + user_data = %UserData{ + addr: ip, + port: port, + name: name, + sesstoken: sesstoken, + passwd: passwd + } + + case find_peer(user_data) do + nil -> add_peer(user_data) + {{peer0, msg0}, {peer1, msg1}} -> + :gen_udp.send(socket, peer0, msg0) + :gen_udp.send(socket, peer1, msg1) + end + + end + + def handle_register(socket, {ip, port, bin}) do + [_, token, passwd] = Regex.run(~r/REGISTER:(\w+):(\w+)/, bin) + if Server.Reg.register_session(token, passwd) do + :gen_udp.send(socket, ip, port, "successful!") + else + :gen_udp.send(socket, ip, port, "exists") + end + end +end +``` + +## 使用方式 +### 客户端 +### 服务端 + ## 后记 -### 分层实现 -### elixir/erlang UI? -查了一下, elixir/erlang的基于文本界面(tui)的库好像都得调用C, 有点难受, -然后:gl官方文档看不懂... ### 感想 +#### 分层实现 +#### 结构化数据 +- markdown用VSCode真香! +### elixir/erlang UI? +查了一下, elixir/erlang的基于文本界面(tui)的库好像都得调用C, 有点难受,
+然后:gl官方文档看不懂... ### Rust杂谈 -正如上文(ui)所说, elixir实现ui总是要调用底层库, 然后OpenGL的支持也没有相关的教程(可能即使实现出来了渲染效率也不高). -还有就是我想知道是elixir里面的一些数据结构如何存储的, 所以我最近准备学一学Rust这个比较底层的语言. -现在刚开始学, 在看the book以及B站相关视频, 然后还是在exercism上做练习, Rust学起来有些地方和之前学elixir挺不同的: +正如上文(ui)所说, elixir实现ui总是要调用底层库, 然后OpenGL的支持也没有相关的教程(可能即使实现出来了渲染效率也不高).
+还有就是我想知道是elixir里面的一些数据结构如何存储的, 所以我最近准备学一学Rust这个比较底层的语言.
+现在刚开始学, 在看the book以及B站相关视频, 然后还是在exercism上做练习, Rust学起来有些地方和之前学elixir挺不同的:
- 静态类型: 我之前一直写动态类型的语言(py, ex, jl), 静态有些不适应, 但其实还好 - 难: 很有挑战性, exercism上的learning exercises没有elixir那么详细 -- 现代, 放心, 受限?: Rust编译器是真滴强大, 静态检查很多细节都能检查出来, 而且所有权让内存管理更安全了, -不像C随便写一些就segfault, 也不用预先定义函数, -```C +- 现代, 放心, 受限?: Rust编译器是真滴强大, 静态检查很多细节都能检查出来, 而且所有权让内存管理更安全了,
+不像C随便写一些就segfault, 也不用预先定义函数 +```c #include int ref(); int main(int argc, char *argv){ - int *a = ref(); - printf("%s", *a); - return 0; + int *a = ref(); + printf("%s", *a); + return 0; } int ref(){ - char *s = "haha"; - return &s; + char *s = "haha"; + return &s; } ``` 但是有些地方(暂时这个水平)觉得有点受限, 没有C那么为所欲为, 但这一段我应该看完unsafe rust在下结论233