├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── fpm.erl ├── postinst ├── ssh-proxy.erl └── ssh-proxy.service /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM erlang:alpine 2 | 3 | LABEL maintaner="Alexander Komlev " 4 | 5 | ENV SSH_PROXY_ROOT=/opt/ssh-proxy 6 | ENV SSH_PROXY_DATA=/opt/data 7 | ENV SSH_PROXY_HOST_KEY=$SSH_PROXY_DATA/server/ssh_host_rsa_key 8 | 9 | RUN mkdir -p \ 10 | $SSH_PROXY_ROOT \ 11 | $SSH_PROXY_DATA/auth \ 12 | $SSH_PROXY_DATA/users \ 13 | $SSH_PROXY_DATA/server 14 | 15 | ADD ssh-proxy.erl $SSH_PROXY_ROOT/ 16 | 17 | RUN apk add --no-cache --virtual deps \ 18 | openssl && \ 19 | openssl genrsa -out $SSH_PROXY_HOST_KEY && \ 20 | apk del deps 21 | 22 | ENTRYPOINT [ \ 23 | "/bin/sh", "-c", \ 24 | "$SSH_PROXY_ROOT/ssh-proxy.erl -i $SSH_PROXY_DATA/auth -u $SSH_PROXY_DATA/users -t $SSH_PROXY_DATA/server" \ 25 | ] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Flussonic, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION=1.0 2 | 3 | DEBIAN = -s dir --url https://github.com/flussonic/ssh-proxy --description "SSH proxy server" \ 4 | -m "Max Lapshin " --vendor "Erlyvideo, LLC" --license MIT \ 5 | --post-install ../postinst --config-files /etc/ssh-proxy/ssh-proxy.conf 6 | 7 | 8 | package: 9 | rm -rf tmproot 10 | mkdir -p tmproot/usr/sbin 11 | cp ssh-proxy.erl tmproot/usr/sbin/ 12 | cd tmproot && ../fpm.erl -f -t deb -n ssh-proxy -v $(VERSION) $(DEBIAN) -a amd64 --category net lib etc/ssh-proxy usr && cd .. 13 | mv tmproot/*.deb . 14 | rm -rf tmproot 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh-proxy 2 | 3 | Most simples Secure Shell proxy to control access of your engineering/support team(s) to private servers. 4 | 5 | 6 | ## Inspiration 7 | 8 | This tool is for a company that provide support to clients, small engineering teams or start-ups. Anyone who needs centralized, secure and simples access governance to private server. It offers an alternative solution to *authorized_keys*. 9 | 10 | **Why not authorized_keys on server?** 11 | 12 | Imagine that you need to ask for root access on client's server. If you put public keys of all your engineers on client's server, then you need to maintain list of client's servers to delete these keys and you need to disclose list of your people. 13 | 14 | All this is a bad idea, especially when you will corrupt authorized_keys on server by running your automation tool and client's simultaneously. 15 | 16 | This tool will allow to put only one public key on server and maintain access through this key. 17 | 18 | Secondly, it solves a problem of SSH access provision in the ad-hoc cloud environment, where new servers automatically comes and goes. 19 | 20 | As **benefits**, you gets 21 | 22 | 1. No LDAP, Kerberos or any other nightmare technologies 23 | 2. No need to share private key with all your team including fired people 24 | 3. All actions are logged so that you will be able to find, who have dropped production database 25 | 26 | Please be aware that solution is still **under development**. 27 | 28 | 29 | ## Key features 30 | 31 | * Secure shell proxy. 32 | * Secure port forwarding via stdio. 33 | * Of-the-shelf deployment to docker-based environments. 34 | 35 | 36 | ## Getting started 37 | 38 | SSH proxy is a daemon that helps you to control access of your support team to customers servers with following workflow: 39 | 40 | 1. You create your team key pair 41 | 2. Give public key to all customers 42 | 3. Store private key on a private server that runs a proxy. The access to this server has to be limited to yourself 43 | 4. Take public key from your support personnel 44 | 5. Upload them on that proxy server 45 | 6. Now your support stuff can login to customer server unless you revoke this access 46 | 47 | Use the proxy to control access of your engineering team to cloud servers with similar workflow 48 | 49 | 1. Use the console to generate key pair(s) for your environment. 50 | 2. Upload the private key to a ssh-proxy server. 51 | 3. Take public key from your engineers (e.g. github identity) 52 | 4. Upload public keys on that proxy server. 53 | 5. Now your support stuff can login to cloud servers unless you revoke this access 54 | 55 | ### Running the proxy 56 | 57 | The easiest way to run Secure Shell proxy is Docker containers, there are available pre-build images at `flussonic/ssh-proxy`. Alternatively, you can use [Erlang escript](http://erlang.org/doc/man/escript.html) to spawn a daemon but it requires an installation of [Erlang OTP/19 or later release](http://www.erlang.org). 58 | 59 | ```bash 60 | docker run -it --rm --name ssh-proxy \ 61 | -p ${CONFIG_SSH_PORT}:2022 \ 62 | -v ${CONFIG_SSH_AUTH}:/opt/data/auth \ 63 | -v ${CONFIG_SSH_USERS}:/opt/data/users \ 64 | flussonic/ssh-proxy 65 | ``` 66 | 67 | Use environment variables or other means to configure the proxy container 68 | 69 | ```bash 70 | ## defines a port used by proxy 71 | export CONFIG_SSH_PORT=2022 72 | 73 | ## location of server's private key 74 | export CONFIG_SSH_AUTH=/tmp/ssh/auth 75 | 76 | ## location of user's publick key. Only these user will be able to build a tunnel 77 | export CONFIG_SSH_USERS=/tmp/ssh/users 78 | ``` 79 | 80 | ### Configure a private key 81 | 82 | Upload a team private key (the key that provisions access to all private servers) `id_rsa` to `${CONFIG_SSH_AUTH}` folder on ssh-proxy server. 83 | 84 | 85 | ### Add/revoke users access 86 | 87 | Upload users public key to `${CONFIG_SSH_USERS}` folder on ssh-proxy server. Name the file after the users name. User's access is revoked if you delete this key from the proxy. 88 | 89 | 90 | ### Establish Secure Shell session 91 | 92 | Your team needs to update `~/.ssh/config` file with details of ssh proxy 93 | 94 | ``` 95 | Host ssh-proxy 96 | HostName 127.0.0.1 97 | Port 2022 98 | User my-user-name 99 | IdentityFile ~/.ssh/my-public-key 100 | ``` 101 | 102 | Please note that proxy has a special syntax to identify private servers. Username, host and ports have to be specified like `user/host/port`. 103 | 104 | ```bash 105 | ssh user/private-host@ssh-proxy 106 | ``` 107 | 108 | 109 | ### Port forwarding 110 | 111 | [Erlang SSH subsystem](http://erlang.org/pipermail/erlang-questions/2018-January/094706.html) do not supports a standard ssh port forwarding. The proxy daemon implements a port forwarding using standard I/O. Using a special syntax: 112 | 113 | ```bash 114 | ssh user/private-host~forward-host/port@ssh-proxy 115 | ``` 116 | 117 | Once SSH connection is established, any `stdin` is delivered to `forward-host/port` and its response available at `stdout` of your local ssh process. A following scripts helps you to attach ssh stdio to any local port. 118 | 119 | ```bash 120 | mkfifo pipe 121 | while [ 1 ] 122 | do 123 | 124 | nc -l 8080 < pipe | ssh -T user/private-host~forward-host/port@ssh-proxy | tee pipe > /dev/null 125 | 126 | done 127 | ``` 128 | 129 | ## How To Contribute 130 | 131 | The project accepts contributions via GitHub pull requests. 132 | 133 | 1. Fork it 134 | 2. Create your feature branch `git checkout -b my-new-feature` 135 | 3. Commit your changes `git commit -am 'Added some feature'` 136 | 4. Push to the branch `git push origin my-new-feature` 137 | 5. Create new Pull Request 138 | 139 | The proxy development requires [Erlang OTP/19 or later release](http://www.erlang.org). 140 | 141 | Use the following command to run the proxy locally for RnD purposes 142 | 143 | ```bash 144 | escript ssh-proxy.erl \ 145 | -p 2022 \ 146 | -i /tmp/ssh/auth \ 147 | -u /tmp/ssh/users \ 148 | -t /tmp/ssh/server 149 | ``` 150 | 151 | -------------------------------------------------------------------------------- /fpm.erl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | 3 | -mode(compile). 4 | -include_lib("kernel/include/file.hrl"). 5 | 6 | -record(fpm, { 7 | target, 8 | output, 9 | force = false, 10 | loglevel = error :: error | verbose | debug, 11 | release, 12 | epoch, 13 | license, 14 | vendor, 15 | category, 16 | depends = [], 17 | url, 18 | description = "no description", 19 | maintainer, 20 | post_install, 21 | pre_uninstall, 22 | post_uninstall, 23 | config_files = [], 24 | name, 25 | replaces = [], 26 | provides = [], 27 | conflicts = [], 28 | suggests = [], 29 | version, 30 | arch, 31 | cwd = ".", 32 | gpg, 33 | rsa, 34 | paths = [], 35 | gpg_program = "gpg" 36 | }). 37 | 38 | 39 | main([]) -> 40 | help(), 41 | erlang:halt(1); 42 | 43 | main(Args) -> 44 | State = getopt(Args), 45 | make_package(State), 46 | ok. 47 | 48 | 49 | fpm_error(Format) -> 50 | fpm_error(Format, []). 51 | 52 | fpm_error(Format, Args) -> 53 | io:format(Format ++ "\n", Args), 54 | halt(1). 55 | 56 | 57 | 58 | % $$$$$$\ $$\ $$\ 59 | % $$ __$$\ $$ | $$ | 60 | % $$ / \__| $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ 61 | % $$ |$$$$\ $$ __$$\\_$$ _| $$ __$$\ $$ __$$\\_$$ _| 62 | % $$ |\_$$ |$$$$$$$$ | $$ | $$ / $$ |$$ / $$ | $$ | 63 | % $$ | $$ |$$ ____| $$ |$$\ $$ | $$ |$$ | $$ | $$ |$$\ 64 | % \$$$$$$ |\$$$$$$$\ \$$$$ |\$$$$$$ |$$$$$$$ | \$$$$ | 65 | % \______/ \_______| \____/ \______/ $$ ____/ \____/ 66 | % $$ | 67 | % $$ | 68 | % \__| 69 | 70 | getopt(Args) -> 71 | parse_args(Args, #fpm{}). 72 | 73 | 74 | 75 | parse_args(["-t", "deb"|Args], #fpm{} = State) -> 76 | parse_args(Args, State#fpm{target = deb}); 77 | 78 | parse_args(["-t", "rpm"|Args], #fpm{} = State) -> 79 | parse_args(Args, State#fpm{target = rpm}); 80 | 81 | parse_args(["-t", "apk"|Args], #fpm{} = State) -> 82 | parse_args(Args, State#fpm{target = apk}); 83 | 84 | parse_args(["-t", Target|_Args], #fpm{} = _State) -> 85 | fpm_error("-t '~s' is not supported\n",[Target]); 86 | 87 | 88 | 89 | parse_args(["-s", "dir" | Args], State) -> 90 | parse_args(Args, State); 91 | 92 | parse_args(["-s", Source | _Args], _State) -> 93 | fpm_error("-s '~s' is not supported", [Source]); 94 | 95 | 96 | parse_args(["-p", Path|Args], #fpm{} = State) -> 97 | parse_args(Args, State#fpm{output = Path}); 98 | 99 | parse_args(["--package", Path|Args], #fpm{} = State) -> 100 | parse_args(Args, State#fpm{output = Path}); 101 | 102 | 103 | parse_args(["-f"|Args], #fpm{} = State) -> 104 | parse_args(Args, State#fpm{force = true}); 105 | 106 | parse_args(["--force"|Args], #fpm{} = State) -> 107 | parse_args(Args, State#fpm{force = true}); 108 | 109 | parse_args(["-n", V|Args], #fpm{} = State) -> 110 | parse_args(Args, State#fpm{name = V}); 111 | 112 | parse_args(["--name", V|Args], #fpm{} = State) -> 113 | parse_args(Args, State#fpm{name = V}); 114 | 115 | 116 | parse_args(["--verbose"|Args], #fpm{} = State) -> 117 | parse_args(Args, State#fpm{loglevel = verbose}); 118 | 119 | parse_args(["--debug"|Args], #fpm{} = State) -> 120 | parse_args(Args, State#fpm{loglevel = debug}); 121 | 122 | 123 | parse_args(["-v", V|Args], #fpm{} = State) -> 124 | parse_args(Args, State#fpm{version = V}); 125 | 126 | parse_args(["--version", V|Args], #fpm{} = State) -> 127 | parse_args(Args, State#fpm{version = V}); 128 | 129 | parse_args(["--iteration", I|Args], #fpm{} = State) -> 130 | parse_args(Args, State#fpm{release = I}); 131 | 132 | 133 | parse_args(["--epoch", E|Args], #fpm{} = State) -> 134 | parse_args(Args, State#fpm{epoch = E}); 135 | 136 | 137 | parse_args(["--license", L|Args], #fpm{} = State) -> 138 | parse_args(Args, State#fpm{license = L}); 139 | 140 | parse_args(["--vendor", L|Args], #fpm{} = State) -> 141 | parse_args(Args, State#fpm{vendor = L}); 142 | 143 | parse_args(["--category", Desc|Args], #fpm{} = State) -> 144 | parse_args(Args, State#fpm{category = Desc}); 145 | 146 | 147 | parse_args(["--depends", Dep|Args], #fpm{depends = Deps} = State) -> 148 | parse_args(Args, State#fpm{depends = Deps ++ [Dep]}); 149 | 150 | parse_args(["-d", Dep|Args], #fpm{depends = Deps} = State) -> 151 | parse_args(Args, State#fpm{depends = Deps ++ [Dep]}); 152 | 153 | 154 | parse_args(["--conflicts", V|Args], #fpm{conflicts = R} = State) -> 155 | parse_args(Args, State#fpm{conflicts = R ++ [V]}); 156 | 157 | parse_args(["--suggests", V|Args], #fpm{suggests = R} = State) -> 158 | parse_args(Args, State#fpm{suggests = R ++ [V]}); 159 | 160 | parse_args(["--replaces", V|Args], #fpm{replaces = R} = State) -> 161 | parse_args(Args, State#fpm{replaces = R ++ [V]}); 162 | 163 | parse_args(["--provides", V|Args], #fpm{provides = P} = State) -> 164 | parse_args(Args, State#fpm{provides = P ++ [V]}); 165 | 166 | parse_args(["--config-files", V|Args], #fpm{config_files = Conf} = State) -> 167 | parse_args(Args, State#fpm{config_files = Conf ++ [V]}); 168 | 169 | 170 | parse_args(["-a", V|Args], #fpm{} = State) -> 171 | parse_args(Args, State#fpm{arch = V}); 172 | 173 | parse_args(["--architecture", V|Args], #fpm{} = State) -> 174 | parse_args(Args, State#fpm{arch = V}); 175 | 176 | 177 | parse_args(["-m", Desc|Args], #fpm{} = State) -> 178 | parse_args(Args, State#fpm{maintainer = Desc}); 179 | 180 | parse_args(["--maintainer", Desc|Args], #fpm{} = State) -> 181 | parse_args(Args, State#fpm{maintainer = Desc}); 182 | 183 | parse_args(["--description", Desc|Args], #fpm{} = State) -> 184 | parse_args(Args, State#fpm{description = Desc}); 185 | 186 | 187 | parse_args(["--url", URL|Args], #fpm{} = State) -> 188 | parse_args(Args, State#fpm{url = URL}); 189 | 190 | parse_args(["--rsa", RSA|Args], #fpm{} = State) -> 191 | parse_args(Args, State#fpm{rsa = RSA}); 192 | 193 | parse_args(["--gpg", GPG|Args], #fpm{} = State) -> 194 | parse_args(Args, State#fpm{gpg = GPG}); 195 | 196 | parse_args(["--gpg-program", File|Args], #fpm{} = State) -> 197 | parse_args(Args, State#fpm{gpg_program = File}); 198 | 199 | parse_args(["--post-install", V|Args], #fpm{} = State) -> 200 | case file:read_file(V) of 201 | {ok, Bin} -> parse_args(Args, State#fpm{post_install = Bin}); 202 | {error, E} -> fpm_error("Failed to read post-install ~s", [E]) 203 | end; 204 | 205 | parse_args(["--post-uninstall", V|Args], #fpm{} = State) -> 206 | case file:read_file(V) of 207 | {ok, Bin} -> parse_args(Args, State#fpm{post_uninstall = Bin}); 208 | {error, E} -> fpm_error("Failed ot read post-uninstall ~s", E) 209 | end; 210 | 211 | parse_args(["--pre-uninstall", V|Args], #fpm{} = State) -> 212 | case file:read_file(V) of 213 | {ok, Bin} -> parse_args(Args, State#fpm{pre_uninstall = Bin}); 214 | {error, E} -> fpm_error("Failed to read pre-uninstall ~s", [E]) 215 | end; 216 | 217 | 218 | 219 | 220 | 221 | parse_args(["--"++Option, _V|Args], #fpm{} = State) -> 222 | io:format("unknown option '~s'\n", [Option]), 223 | parse_args(Args, State); 224 | 225 | parse_args(["-"++Option, _V|Args], #fpm{} = State) -> 226 | io:format("unknown option '~s'\n", [Option]), 227 | parse_args(Args, State); 228 | 229 | parse_args(Paths, #fpm{} = State) -> 230 | State#fpm{paths = Paths}. 231 | 232 | 233 | 234 | 235 | validate_package(#fpm{name = undefined}) -> 236 | fpm_error("name is required"); 237 | 238 | validate_package(#fpm{arch = undefined}) -> 239 | fpm_error("arch is required"); 240 | 241 | validate_package(#fpm{version = undefined}) -> 242 | fpm_error("version is required"); 243 | 244 | validate_package(_) -> 245 | ok. 246 | 247 | 248 | make_package(#fpm{target = Target} = FPM) -> 249 | validate_package(FPM), 250 | case Target of 251 | deb -> debian(FPM); 252 | rpm -> rpm(FPM); 253 | apk -> apk(FPM) 254 | end. 255 | 256 | 257 | 258 | apk(#fpm{target = apk, name = Name, version = Version, output = OutPath} = State) -> 259 | Path = case OutPath of 260 | undefined -> Name++"-" ++ Version ++ ".apk"; 261 | _ -> OutPath 262 | end, 263 | Arch = case State#fpm.arch of 264 | "amd64" -> "x86_64"; 265 | Arch0 -> Arch0 266 | end, 267 | debian_data(State), 268 | Datahash = hex(crypto:hash(sha256,element(2,file:read_file("data.tar.gz")))), 269 | 270 | Meta = [ 271 | {pkgname, Name}, 272 | {pkgver, Version}, 273 | {pkgdesc, State#fpm.description}, 274 | {url, State#fpm.url}, 275 | {builddate, integer_to_binary(os:system_time(seconds))}, 276 | {maintainer, State#fpm.maintainer}, 277 | {size, <<"102400">>}, 278 | {arch, Arch}, 279 | {license,<<"EULA">>}, 280 | {datahash,Datahash} 281 | ], 282 | ok = file:write_file(".PKGINFO", [[atom_to_binary(K,latin1)," = ",V,"\n"] || {K,V} <- Meta, V =/= undefined]), 283 | "" = os:cmd(tar()++" --owner=root --numeric-owner --group 0 --no-recursion -cf control.tar .PKGINFO"), 284 | Deploy = filename:dirname(escript:script_name()), 285 | "" = os:cmd(Deploy++"/apk-tar.rb control.tar"), 286 | os:cmd("gzip control.tar"), 287 | file:delete(".PKGINFO"), 288 | 289 | case State of 290 | #fpm{rsa = undefined} -> 291 | file:delete("sign.tar.gz"); 292 | #fpm{rsa = RsaPath} -> 293 | % openssl genrsa -out ~/.ssh/max@flussonic.com.rsa 2048 294 | % openssl rsa -in ~/.ssh/max@flussonic.com.rsa -pubout > ~/.ssh/max@flussonic.com.rsa.pub 295 | RsaName = filename:basename(RsaPath), 296 | SignPath = ".SIGN.RSA."++RsaName++".pub", 297 | os:cmd("openssl dgst -sha1 -sign "++RsaPath++" -out "++SignPath++" control.tar.gz"), 298 | "" = os:cmd(tar()++" --owner=root --numeric-owner --group 0 --no-recursion -cf sign.tar "++SignPath), 299 | os:cmd(Deploy++"/apk-tar.rb sign.tar"), 300 | file:delete(SignPath), 301 | os:cmd("gzip sign.tar") 302 | end, 303 | 304 | file:write_file(Path, [ 305 | case file:read_file("sign.tar.gz") of 306 | {ok, Sign} -> Sign; 307 | _ -> <<>> 308 | end, 309 | element(2,file:read_file("control.tar.gz")), 310 | element(2,file:read_file("data.tar.gz")) 311 | ]), 312 | file:delete("sign.tar.gz"), 313 | file:delete("control.tar.gz"), 314 | file:delete("data.tar.gz"), 315 | ok. 316 | 317 | 318 | % $$$$$$$\ $$\ $$\ 319 | % $$ __$$\ $$ | \__| 320 | % $$ | $$ | $$$$$$\ $$$$$$$\ $$\ $$$$$$\ $$$$$$$\ 321 | % $$ | $$ |$$ __$$\ $$ __$$\ $$ | \____$$\ $$ __$$\ 322 | % $$ | $$ |$$$$$$$$ |$$ | $$ |$$ | $$$$$$$ |$$ | $$ | 323 | % $$ | $$ |$$ ____|$$ | $$ |$$ |$$ __$$ |$$ | $$ | 324 | % $$$$$$$ |\$$$$$$$\ $$$$$$$ |$$ |\$$$$$$$ |$$ | $$ | 325 | % \_______/ \_______|\_______/ \__| \_______|\__| \__| 326 | 327 | 328 | 329 | debian(#fpm{target = deb, name = Name, version = Version, arch = Arch, output = OutPath, force = Force} = State) -> 330 | Path = case OutPath of 331 | undefined -> Name++"_" ++ Version++ "_" ++ Arch ++ ".deb"; 332 | _ -> OutPath 333 | end, 334 | case file:read_file_info(Path) of 335 | {ok, _} when Force -> 336 | file:delete(Path); 337 | {ok, _} -> 338 | fpm_error("Error: file '~s' exists, not overwriting", [Path]); 339 | {error, enoent} -> 340 | ok; 341 | {error, Error} -> 342 | fpm_error("Error: cannot access output file '~s': ~p", [Path, Error]) 343 | end, 344 | 345 | debian_control(State), 346 | debian_data(State), 347 | file:write_file("debian-binary", "2.0\n"), 348 | os:cmd("ar qc "++Path++" debian-binary control.tar.gz data.tar.gz"), 349 | file:delete("control.tar.gz"), 350 | file:delete("data.tar.gz"), 351 | file:delete("debian-binary"), 352 | ok. 353 | 354 | 355 | debian_control(#fpm{post_install = Postinst, pre_uninstall = Prerm, post_uninstall = Postrm} = State) -> 356 | Files = [{"control", debian_control_content(State)}] ++ 357 | debian_possible_file(conffiles, debian_conf_files(State)) ++ 358 | debian_possible_file(postinst, Postinst) ++ 359 | debian_possible_file(prerm, Prerm) ++ 360 | debian_possible_file(postrm, Postrm), 361 | file:delete("control.tar.gz"), 362 | erl_tar:create("control.tar.gz", Files, [compressed]), 363 | ok. 364 | 365 | debian_possible_file(_, undefined) -> []; 366 | debian_possible_file(Name, Content) -> [{atom_to_list(Name),iolist_to_binary(Content)}]. 367 | 368 | debian_conf_files(#fpm{config_files = Conf}) -> 369 | [[C,"\n"] || C <- Conf]. 370 | 371 | debian_control_content(#fpm{name = Name, version = Version, maintainer = Maintainer, conflicts = Conflicts, 372 | arch = Arch, suggests = Suggests, depends = Depends, provides = Provides, 373 | replaces = Replaces, category = Category, url = URL, description = Description}) -> 374 | Content = [ 375 | debian_header("Package", Name), 376 | debian_header("Version", Version), 377 | debian_header("Architecture", Arch), 378 | debian_header("Maintainer", Maintainer), 379 | debian_header("Depends", join_list(Depends)), 380 | debian_header("Provides", join_list(Provides)), 381 | debian_header("Conflicts", join_list(Conflicts)), 382 | debian_header("Suggests", join_list(Suggests)), 383 | debian_header("Replaces", join_list(Replaces)), 384 | debian_header("Standards-Version", "3.9.1"), 385 | debian_header("Section",Category), 386 | debian_header("Priority", "extra"), 387 | debian_header("Homepage", URL), 388 | debian_header("Description", Description) 389 | ], 390 | iolist_to_binary(Content). 391 | 392 | debian_header(_, undefined) -> ""; 393 | debian_header(Key, Value) -> [Key, ": ", Value, "\n"]. 394 | 395 | join_list([]) -> undefined; 396 | join_list(Items) -> string:join(Items, ", "). 397 | 398 | 399 | 400 | debian_data(#fpm{paths = Paths}) -> 401 | AllPaths = debian_lookup_files(Paths), 402 | file:delete("data.tar.gz"), 403 | % {ok, Tar} = erl_tar:open("data.tar.gz", [write,compressed]), 404 | % lists:foreach(fun(Path) -> 405 | % case filelib:is_dir(Path) of 406 | % true -> 407 | 408 | % end, AllPaths), 409 | {Dirs, Files} = lists:partition(fun filelib:is_dir/1, AllPaths), 410 | % io:format("dirs: ~p\n",[Dirs]), 411 | % io:format("files: ~p\n",[Files]), 412 | "" = os:cmd(tar()++" --owner=root --numeric-owner --group 0 --no-recursion -cf data.tar "++string:join(Dirs, " ")), 413 | file:write_file("tmpfilelist.txt", [ [F,"\n"] || F <- Files]), 414 | "" = os:cmd(tar()++" --owner=root --numeric-owner --group 0 -rf data.tar -T tmpfilelist.txt"), 415 | "" = os:cmd("gzip data.tar"), 416 | file:delete("tmpfilelist.txt"), 417 | % os:cmd(tar()++" --owner=root --group=root --no-recursion -cf data.tar.gz "++string:join(AllPaths, " ")), 418 | ok. 419 | 420 | debian_lookup_files(Dirs) -> 421 | debian_lookup_files(Dirs, sets:new()). 422 | 423 | debian_lookup_files([], Set) -> 424 | lists:usort(sets:to_list(Set)); 425 | 426 | debian_lookup_files([Dir|Dirs], Set) -> 427 | Set1 = filelib:fold_files(Dir, ".*", true, fun(Path, Acc) -> 428 | debian_add_recursive_path(Path, Acc) 429 | end, Set), 430 | debian_lookup_files(Dirs, Set1). 431 | 432 | 433 | debian_add_recursive_path("/", Set) -> 434 | Set; 435 | 436 | debian_add_recursive_path(".", Set) -> 437 | Set; 438 | 439 | debian_add_recursive_path(Path, Set) -> 440 | debian_add_recursive_path(filename:dirname(Path), sets:add_element(Path, Set)). 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | tar() -> 450 | case os:type() of 451 | {unix,darwin} -> "gnutar"; 452 | {unix,linux} -> "tar" 453 | end. 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | % $$$$$$$\ $$$$$$$\ $$\ $$\ 462 | % $$ __$$\ $$ __$$\ $$$\ $$$ | 463 | % $$ | $$ |$$ | $$ |$$$$\ $$$$ | 464 | % $$$$$$$ |$$$$$$$ |$$\$$\$$ $$ | 465 | % $$ __$$< $$ ____/ $$ \$$$ $$ | 466 | % $$ | $$ |$$ | $$ |\$ /$$ | 467 | % $$ | $$ |$$ | $$ | \_/ $$ | 468 | % \__| \__|\__| \__| \__| 469 | 470 | 471 | 472 | rpm(#fpm{paths = Dirs0, output = OutPath, force = Force, name = Name0, version = Version0, arch = Arch0, release = Release0} = FPM) -> 473 | Arch1 = case Arch0 of 474 | "amd64" -> "x86_64"; 475 | _ -> Arch0 476 | end, 477 | 478 | Release1 = case Release0 of 479 | undefined -> "1"; 480 | _ -> Release0 481 | end, 482 | 483 | RPMPath = case OutPath of 484 | undefined -> Name0++"-" ++ Version0++ "-" ++ Release1 ++ "." ++ Arch1 ++ ".rpm"; 485 | _ -> OutPath 486 | end, 487 | case file:read_file_info(RPMPath) of 488 | {ok, _} when Force -> 489 | file:delete(RPMPath); 490 | {ok, _} -> 491 | fpm_error("Error: file '~s' exists, not overwriting", [RPMPath]); 492 | {error, enoent} -> 493 | ok; 494 | {error, Error} -> 495 | fpm_error("Error: cannot access output file '~s': ~p", [RPMPath, Error]) 496 | end, 497 | 498 | Name = iolist_to_binary(Name0), 499 | Version = iolist_to_binary(Version0), 500 | Arch = iolist_to_binary(Arch1), 501 | Release = iolist_to_binary(Release1), 502 | 503 | % It is a problem: how to store directory names. RPM requires storing them in "/etc/" and "flussonic.conf" 504 | % cpio required: "etc/flussonic.conf" 505 | Dirs = lists:map(fun 506 | ("./" ++ Dir) -> Dir; 507 | ("/" ++ _ = Dir) -> error({absoulte_dir_not_allowed,Dir}); 508 | (Dir) -> Dir 509 | end, Dirs0), 510 | 511 | % Need to sort files because mapFind will make bsearch to find them 512 | Files0 = rpm_load_file_list(Dirs), 513 | IsFile = fun(A) -> 514 | case file:read_file_info(A) of 515 | {ok, #file_info{type = regular}} -> true; 516 | _ -> false 517 | end 518 | end, 519 | Files = [F || F <- Files0, IsFile(F)], 520 | CPIO = zlib:gzip(cpio(Files)), 521 | 522 | Info1 = [ 523 | {summary, FPM#fpm.description}, 524 | {description, FPM#fpm.description}, 525 | % {buildhost, <<"dev.flussonic.com">>}, 526 | {vendor, FPM#fpm.vendor}, 527 | {license, FPM#fpm.license}, 528 | {packager, FPM#fpm.maintainer}, 529 | {group, FPM#fpm.category}, 530 | {url, FPM#fpm.url} 531 | ], 532 | 533 | Info2 = [{K,iolist_to_binary(V)} || {K,V} <- Info1, V =/= undefined], 534 | 535 | HeaderAddedTags = Info2 ++ [{name,Name},{version,Version},{release,Release},{arch,Arch},{size,iolist_size(CPIO)}], 536 | 537 | #fpm{post_install=PostInst,pre_uninstall=PreRm,post_uninstall=PostRm}=FPM, 538 | #fpm{epoch = Epoch}=FPM, 539 | HeaderAddedTags2 = lists:foldl(fun 540 | ({T, V}, Acc) when V /= undefined -> 541 | [{T, V} | Acc]; 542 | (_, Acc) -> Acc 543 | end, HeaderAddedTags, 544 | [ 545 | {postinstall, set_scriptlet_env(Name, Version, PostInst)}, 546 | {preuninstall, set_scriptlet_env(Name, Version, PreRm)}, 547 | {postuninstall, set_scriptlet_env(Name, Version, PostRm)}, 548 | {epoch, Epoch} 549 | ]), 550 | 551 | HeaderAddedTags3 = HeaderAddedTags2 ++ rpm_depends_tags(FPM) ++ rpm_provides_tags(FPM), 552 | Header = rpm_header(HeaderAddedTags3, Files, FPM), 553 | MD5 = crypto:hash(md5, [Header, CPIO]), 554 | 555 | GPGSign = case FPM#fpm.gpg of 556 | undefined -> 557 | []; 558 | GPG -> 559 | file:write_file("signed-data", [Header, CPIO]), 560 | GPGCmd = FPM#fpm.gpg_program++" --batch --no-armor --no-secmem-warning -u "++GPG++" -sbo out.sig signed-data", 561 | os:cmd(GPGCmd), 562 | case file:read_file_info("out.sig") of 563 | {error, _} -> io:format("Error run cmd:~p\n", [GPGCmd]); 564 | _ -> io:format(GPGCmd) 565 | end, 566 | {ok, PGP} = file:read_file("out.sig"), 567 | file:delete("signed-data"), 568 | file:delete("out.sig"), 569 | 570 | file:write_file("signed-data", [Header]), 571 | os:cmd(FPM#fpm.gpg_program++" --batch --no-armor --no-secmem-warning -u "++GPG++" -sbo out.sig signed-data"), 572 | {ok, RSA} = file:read_file("out.sig"), 573 | file:delete("signed-data"), 574 | file:delete("out.sig"), 575 | 576 | [{pgp_header,{bin,PGP}},{rsa_header,{bin,RSA}}] 577 | end, 578 | 579 | 580 | Signature = [{sha1_header,hex(crypto:hash(sha, [Header]))}] ++ GPGSign++ 581 | [{signature_size,iolist_size(Header) + iolist_size(CPIO)}, 582 | {md5_header,{bin,MD5}}], 583 | 584 | 585 | {ok, F} = file:open(RPMPath, [binary, write, raw]), 586 | ok = file:write(F, rpm_lead(Name)), 587 | ok = file:write(F, rpm_signatures(Signature)), 588 | ok = file:write(F, Header), 589 | % {ok, CpioPos} = file:position(F, cur), 590 | % io:format("Write cpio at offset ~B\n", [CpioPos]), 591 | ok = file:write(F, CPIO), 592 | % dump_cpio0(iolist_to_binary(zlib:gunzip(CPIO))), 593 | ok. 594 | 595 | hex(Bin) -> 596 | iolist_to_binary(string:to_lower(lists:flatten([io_lib:format("~2.16.0B", [I]) || <> <= Bin]))). 597 | 598 | rpm_lead(Name) -> 599 | Magic = <<16#ed, 16#ab, 16#ee, 16#db>>, 600 | Major = 3, 601 | Minor = 0, 602 | Type = 0, 603 | Arch = 1, 604 | 605 | OS = 1, % Linux 606 | SigType = 5, % new "Header-style" signatures 607 | Name0 = iolist_to_binary([Name, binary:copy(<<0>>, 66 - size(Name))]), 608 | 609 | Reserve = binary:copy(<<0>>, 16), 610 | Lead = <>, 611 | 96 = size(Lead), 612 | Lead. 613 | 614 | 615 | 616 | rpm_signatures(Headers) -> 617 | {_Magic,Index0, Data0} = rpm_pack_header(Headers), 618 | 619 | HeaderSign = <<0,0,0,62, 0,0,0,7, (-(iolist_size(Index0)+16)):32/signed, 0,0,0,16>>, 620 | {Magic,Index, Data} = rpm_magic(length(Headers)+1, [rpm_pack_index({header_signatures,bin,iolist_size(Data0),size(HeaderSign)})|Index0], [Data0,HeaderSign]), 621 | 622 | Pad = rpm_pad8(Data), 623 | % io:format("Write signature index_size:~B, header_size:~B, pad:~B\n", [iolist_size(Index), iolist_size(Data), iolist_size(Pad)]), 624 | [Magic, Index, [Data,Pad]]. 625 | 626 | rpm_pad8(Data) -> rpm_pad(Data, 8). 627 | % pad4(Data) -> pad(Data, 4). 628 | 629 | rpm_pad(Data, N) -> 630 | Pad = binary:copy(<<0>>, N - (iolist_size(Data) rem N)), 631 | Pad. 632 | 633 | 634 | rpm_load_file_list(Dirs) -> 635 | Files1 = lists:usort(lists:flatmap(fun(Dir) -> rpm_list(Dir) end, Dirs)), 636 | Files2 = Files1 -- [<<"etc">>, <<"etc/init.d">>, <<"opt">>], 637 | Files2. 638 | 639 | rpm_list(Dir) -> 640 | Files1 = filelib:fold_files(Dir, ".*", true, fun(P,L) -> [list_to_binary(P)|L] end, []), 641 | Files2 = lists:filter(fun(Path) -> 642 | {ok, #file_info{type = T}} = file:read_file_info(Path), 643 | T == regular 644 | end, Files1), 645 | Files3 = lists:flatmap(fun(Path) -> 646 | rpm_ancestors(Path) 647 | end, Files2), 648 | Files3. 649 | 650 | rpm_ancestors(Path) -> 651 | case filename:dirname(Path) of 652 | <<"/">> -> []; 653 | <<".">> -> []; 654 | <<"./">> -> []; 655 | Root -> [Path|rpm_ancestors(Root)] 656 | end. 657 | 658 | 659 | utc({{_Y,_Mon,_D},{_H,_Min,_S}} = DateTime) -> 660 | calendar:datetime_to_gregorian_seconds(DateTime) - calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}). 661 | 662 | 663 | 664 | 665 | 666 | cpio_pad4(I) when I rem 4 == 0 -> 0; 667 | cpio_pad4(I) -> 4 - (I rem 4). 668 | 669 | 670 | 671 | to_b(I) when is_integer(I) -> 672 | iolist_to_binary(string:to_lower(lists:flatten(io_lib:format("~8.16.0B", [I])))). 673 | 674 | cpio([]) -> 675 | cpio_pack("TRAILER!!!", 0, 0, 0, 0); 676 | 677 | cpio([Path|Paths]) -> 678 | Rest = cpio(Paths), 679 | {ok, #file_info{inode = Inode, size = Size, mode = Mode, type = Type, links = Nlinks}} = file:read_file_info(Path), 680 | case Type of 681 | regular -> 682 | Pack1 = cpio_pack(<<"/", Path/binary>>, Size, Inode, Mode, Nlinks), 683 | Pad2 = binary:copy(<<0>>, cpio_pad4(Size)), 684 | {ok, Bin} = file:read_file(Path), 685 | Pack1 ++ [Bin, Pad2] ++ Rest; 686 | directory -> 687 | Pack1 = cpio_pack(<<"/", Path/binary>>, 0, Inode, Mode, Nlinks), 688 | Pack1 ++ Rest 689 | end. 690 | 691 | 692 | 693 | now_s() -> 694 | {Mega, Sec, _} = os:timestamp(), 695 | Mega*1000000 + Sec. 696 | 697 | cpio_pack(Name, Size, Inode, Mode, Nlinks) -> 698 | % Nlinks = if 699 | % Inode == 0 -> 0; 700 | % Size == 0 andalso Mode =/= regular -> 2; 701 | % true -> 1 702 | % end, 703 | Major = case Inode of 704 | 0 -> 0; 705 | _ -> 263 706 | end, 707 | ["070701", to_b(Inode), to_b(Mode), to_b(0), to_b(0), to_b(Nlinks), to_b(now_s()), to_b(Size), to_b(Major), to_b(0), to_b(Major), to_b(0), 708 | to_b(iolist_size(Name)+1), to_b(0), Name, 0, binary:copy(<<0>>, cpio_pad4(iolist_size(Name) + 1 + 110))]. 709 | 710 | 711 | rpm_depends_tags(#fpm{depends=Depends}) -> 712 | Deps = lists:foldl(fun(Depend, Acc) -> 713 | case rpm_parse_depend(Depend) of 714 | undefined -> Acc; 715 | V -> [V | Acc] 716 | end 717 | end, [], Depends), 718 | case lists:unzip3(Deps) of 719 | {[], _, _} -> []; 720 | {Names, Versions, Flags} -> 721 | [ 722 | {requirename, Names}, 723 | {requireversion, Versions}, 724 | {requireflags, Flags} 725 | ] 726 | end. 727 | 728 | rpm_provides_tags(#fpm{provides=Provides}) -> 729 | Prvs = lists:foldl(fun(Prv, Acc) -> 730 | case rpm_parse_depend(Prv) of 731 | undefined -> Acc; 732 | V -> [V | Acc] 733 | end 734 | end, [], Provides), 735 | case lists:unzip3(Prvs) of 736 | {[], _, _} -> []; 737 | {Names, Versions, Flags} -> 738 | [ 739 | {providename, Names}, 740 | {provideversion, Versions}, 741 | {provideflags, Flags} 742 | ] 743 | end. 744 | 745 | rpm_attr_calc([], Acc) -> Acc; 746 | rpm_attr_calc([$< | T], Acc) -> rpm_attr_calc(T, Acc + 2); 747 | rpm_attr_calc([$> | T], Acc) -> rpm_attr_calc(T, Acc + 4); 748 | rpm_attr_calc([$= | T], Acc) -> rpm_attr_calc(T, Acc + 8). 749 | 750 | rpm_parse_depend(L) -> 751 | Trim = fun(V) -> string:strip(V, both, 32) end, 752 | Bin = fun(V) -> 753 | list_to_binary(Trim(V)) 754 | end, 755 | Attr = fun(V) -> 756 | T = Trim(V), 757 | case length(T) > 2 of 758 | true -> undefined; 759 | false -> rpm_attr_calc(T, 0) 760 | end 761 | end, 762 | case re:run(Trim(L),"^([^<=>]+)(([<=>]+)(.+))?$",[global,{capture,all,list}]) of 763 | {match, [[_, Name]]} -> {Bin(Name), <<>>, 0}; 764 | {match, [[_, Name, _, Op, Version]]} -> 765 | case Attr(Op) of 766 | undefined -> undefined; 767 | V -> {Bin(Name), Bin(Version), V} 768 | end; 769 | _ -> undefined 770 | end. 771 | 772 | 773 | filedigest(_Filename, #file_info{type = directory}) -> 774 | hex(crypto:hash(md5, <<>>)); 775 | 776 | filedigest(Filename, #file_info{}) -> 777 | {ok, Raw} = file:read_file(Filename), 778 | hex(crypto:hash(md5, Raw)). 779 | 780 | 781 | 782 | rpm_header(Addons, Files, #fpm{}=FPM) -> 783 | Infos = [begin 784 | {ok, Info} = file:read_file_info(File), 785 | Info 786 | end || File <- Files], 787 | 788 | Dirs0 = lists:usort([filename:dirname(F) || F <- Files]), 789 | Dirs = lists:zip(Dirs0, lists:seq(0,length(Dirs0)-1)), 790 | Headers = [ 791 | {headeri18ntable, [<<"C">>]} 792 | ] ++ 793 | Addons ++ 794 | [ 795 | {buildtime, utc(erlang:universaltime())}, 796 | {os, <<"linux">>}, 797 | {filesizes, [Size || #file_info{size = Size} <- Infos]}, 798 | {filemodes, {int16, [Mode || #file_info{mode = Mode} <- Infos]}}, 799 | {filemtimes, [utc(Mtime) || #file_info{mtime = Mtime} <- Infos]}, 800 | {fileflags, [case re:run(F, "etc/") of 801 | {match, _} -> 17; % Here we must put proper flags on configuration files 802 | _ -> 2 % Look for typedef enum rpmfileAttrs_e in rpmfi.h 803 | end || F <- Files]}, 804 | {fileusername, [<<"root">> || _ <- Files]}, 805 | {filegroupname, [<<"root">> || _ <- Files]}, 806 | {filelinktos, [<<>> || _ <- Files]}, 807 | {filerdevs, [0 || _ <- Files]}, 808 | 809 | {rpmversion, <<"4.8.0">>}, 810 | {fileinodes, [inode(F) || F <- Files]}, 811 | {filelangs, [<<>> || _ <- Files]}, 812 | 813 | {dirindexes, [proplists:get_value(filename:dirname(F),Dirs) || F <- Files]}, 814 | {basenames, [filename:basename(File) || File <- Files]}, 815 | 816 | % подписи безусловно проверяются в rpm 4.4 817 | {filedigests, [filedigest(File, FI) || {File, #file_info{}=FI} <- lists:zip(Files, Infos)]}, 818 | 819 | {dirnames, [<<"/", Dir/binary, "/">> || {Dir, _} <- Dirs]}, 820 | 821 | {payloadformat, <<"cpio">>}, 822 | {payloadcompressor, <<"gzip">>}, 823 | {payloadflags, <<"2">>}, 824 | {platform, <<"x86_64-redhat-linux-gnu">>}, 825 | {filecolors, [0 || _ <- Files]}, 826 | {fileclass, [1 || _ <- Files]}, 827 | {classdict, [<<>>, <<"file">>]}, 828 | {filedependsx, [0 || _ <- Files]}, 829 | {filedependsn, [0 || _ <- Files]}, 830 | 831 | % для совместимости с rpm 4.8 и 4.4 использую md5 алгоритм для поля filedigests 832 | % в 4.4 этого выбора вообще не было и md5 был прибит гвоздями 833 | % для более новых - надо указать 834 | % https://github.com/rpm-software-management/rpm/blob/master/rpmio/rpmpgp.h#L257 835 | {filedigestalgo, [1]} 836 | ] ++ rpm_control(FPM), 837 | 838 | {_,Index0, Data0} = rpm_pack_header(Headers), 839 | % Data1 = [Data0, align(16, iolist_size(Data0))], 840 | Data1 = Data0, 841 | 842 | % Here goes very important thing: signing with immutable signature. If you move it one byte left-right, everything 843 | % will be lost. 844 | % 845 | % Immutable is a tag that is located in the end of header payload and it looks like index record. It is very confusing. 846 | % It has a negative offset and this offset MUST be equal to the size of index _without_ this tag. 847 | 848 | Immutable = <<0,0,0,63, 0,0,0,7, (-(iolist_size(Index0)+16)):32, 0,0,0,16>>, 849 | {Magic, Index, Data} = rpm_magic(length(Headers)+1, [rpm_pack_index({headerimmutable,bin,iolist_size(Data1),size(Immutable)})|Index0], [Data1,Immutable]), 850 | 851 | % io:format("header. index: ~B entries, ~B bytes, data: ~B bytes\n", [length(Headers)+1, iolist_size(Index), iolist_size(Data)]), 852 | [Magic, Index, Data]. 853 | 854 | 855 | rpm_control(#fpm{post_install = Postinst, pre_uninstall = Prerm, post_uninstall = Postrm}) -> 856 | case Postinst of undefined -> []; _ -> [{postinstall,Postinst}] end ++ 857 | case Prerm of undefined -> []; _ -> [{preuninstall,Prerm}] end ++ 858 | case Postrm of undefined -> []; _ -> [{postuninstall,Postrm}] end. 859 | 860 | 861 | inode(File) -> 862 | {ok, #file_info{inode = Inode}} = file:read_file_info(File), 863 | Inode. 864 | 865 | 866 | 867 | 868 | 869 | 870 | % $$\ $$\ $$\ $$\ $$\ $$\ 871 | % $$ | $\ $$ | \__| $$ | $$ | $$ | 872 | % $$ |$$$\ $$ | $$$$$$\ $$\ $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$$ | $$$$$$\ $$$$$$\ 873 | % $$ $$ $$\$$ |$$ __$$\ $$ |\_$$ _| $$ __$$\ $$ __$$\ $$ __$$\ \____$$\ $$ __$$ |$$ __$$\ $$ __$$\ 874 | % $$$$ _$$$$ |$$ | \__|$$ | $$ | $$$$$$$$ | $$ | $$ |$$$$$$$$ | $$$$$$$ |$$ / $$ |$$$$$$$$ |$$ | \__| 875 | % $$$ / \$$$ |$$ | $$ | $$ |$$\ $$ ____| $$ | $$ |$$ ____|$$ __$$ |$$ | $$ |$$ ____|$$ | 876 | % $$ / \$$ |$$ | $$ | \$$$$ |\$$$$$$$\ $$ | $$ |\$$$$$$$\ \$$$$$$$ |\$$$$$$$ |\$$$$$$$\ $$ | 877 | % \__/ \__|\__| \__| \____/ \_______| \__| \__| \_______| \_______| \_______| \_______|\__| 878 | 879 | 880 | 881 | rpm_pack_header(Headers) -> 882 | {Index, Data} = rpm_pack_header0(Headers, [], [], 0), 883 | rpm_magic(length(Headers), Index, Data). 884 | 885 | rpm_magic(EntryCount, Index, Data) -> 886 | Bytes = iolist_size(Data), 887 | Magic = <<16#8e, 16#ad, 16#e8, 16#01, 0:32, EntryCount:32, Bytes:32>>, 888 | % io:format("pack magic: entries:~B, bytes:~B\n", [EntryCount, Bytes]), 889 | {Magic,Index, Data}. 890 | 891 | 892 | rpm_pack_header0([], Index, Data, _) -> 893 | {lists:reverse([rpm_pack_index(I) || I <- Index]), lists:reverse(Data)}; 894 | 895 | rpm_pack_header0([{Key,{bin,Value}}|Headers], Index, Data, Offset) when is_binary(Value) -> 896 | rpm_pack_header0(Headers, [{Key,bin,Offset,size(Value)}|Index], [Value|Data], Offset + size(Value)); 897 | 898 | rpm_pack_header0([{Key,Value}|Headers], Index, Data, Offset) when is_integer(Value) -> 899 | Align = rpm_align(4, Offset), 900 | % Align = <<>>, 901 | rpm_pack_header0(Headers, [{Key,int32,Offset+size(Align),1}|Index], [<>, Align|Data], Offset + size(Align) + 4); 902 | 903 | rpm_pack_header0([{Key,{int16, Values}}|Headers], Index, Data, Offset) -> 904 | Align = rpm_align(2, Offset), 905 | % Align = <<>>, 906 | rpm_pack_header0(Headers, [{Key,int16,Offset+size(Align),length(Values)}|Index], [[<> || V <- Values],Align|Data], Offset + size(Align) + 2*length(Values)); 907 | 908 | rpm_pack_header0([{Key,[Value|_] = Values}|Headers], Index, Data, Offset) when is_integer(Value) -> 909 | Align = rpm_align(4, Offset), 910 | % Align = <<>>, 911 | rpm_pack_header0(Headers, [{Key,int32,Offset+size(Align),length(Values)}|Index], [[<> || V <- Values],Align|Data], Offset + size(Align) + 4*length(Values)); 912 | 913 | rpm_pack_header0([{Key,Value}|Headers], Index, Data, Offset) when is_binary(Value) -> 914 | String = <>, 915 | Pad = <<>>, 916 | rpm_pack_header0(Headers, [{Key,string,Offset,1}|Index], [Pad,String|Data], Offset + size(String)+size(Pad)); 917 | 918 | rpm_pack_header0([{Key,[Value|_] = Values}|Headers], Index, Data, Offset) when is_binary(Value) -> 919 | Size = lists:sum([size(V) + 1 || V <- Values]), 920 | rpm_pack_header0(Headers, [{Key,string_array,Offset,length(Values)}|Index], [[<> || V <- Values]|Data], Offset + Size). 921 | 922 | 923 | rpm_align(N, Offset) when Offset rem N == 0 -> <<>>; 924 | rpm_align(N, Offset) -> binary:copy(<<0>>, N - (Offset rem N)). 925 | 926 | 927 | 928 | rpm_pack_index({Tag, Type, Offset, Count}) -> 929 | <<(rpm_write_tag(Tag)):32, (rpm_write_type(Type)):32, Offset:32, Count:32>>. 930 | 931 | 932 | 933 | 934 | 935 | 936 | 937 | % $$$$$$$\ $$\ $$$$$$$$\ 938 | % $$ __$$\ $$ | \__$$ __| 939 | % $$ | $$ | $$$$$$\ $$$$$$\ $$$$$$\ $$ |$$\ $$\ $$$$$$\ $$$$$$\ $$$$$$$\ 940 | % $$ | $$ | \____$$\\_$$ _| \____$$\ $$ |$$ | $$ |$$ __$$\ $$ __$$\ $$ _____| 941 | % $$ | $$ | $$$$$$$ | $$ | $$$$$$$ | $$ |$$ | $$ |$$ / $$ |$$$$$$$$ |\$$$$$$\ 942 | % $$ | $$ |$$ __$$ | $$ |$$\ $$ __$$ | $$ |$$ | $$ |$$ | $$ |$$ ____| \____$$\ 943 | % $$$$$$$ |\$$$$$$$ | \$$$$ |\$$$$$$$ | $$ |\$$$$$$$ |$$$$$$$ |\$$$$$$$\ $$$$$$$ | 944 | % \_______/ \_______| \____/ \_______| \__| \____$$ |$$ ____/ \_______|\_______/ 945 | % $$\ $$ |$$ | 946 | % \$$$$$$ |$$ | 947 | % \______/ \__| 948 | 949 | 950 | 951 | 952 | rpm_write_type(T) when is_atom(T) -> 953 | case lists:keyfind(T, 2, rpm_types()) of 954 | {I,T} -> I; 955 | false -> error({unknown_type, T}) 956 | end. 957 | 958 | 959 | rpm_types() -> 960 | [{0,null}, 961 | {1,char}, 962 | {2,int8}, 963 | {3,int16}, 964 | {4,int32}, 965 | {5,int64}, 966 | {6,string}, 967 | {7,bin}, 968 | {8,string_array}, 969 | {9,i18n_string} 970 | ]. 971 | 972 | 973 | 974 | 975 | % $$$$$$$$\ 976 | % \__$$ __| 977 | % $$ | $$$$$$\ $$$$$$\ $$$$$$$\ 978 | % $$ | \____$$\ $$ __$$\ $$ _____| 979 | % $$ | $$$$$$$ |$$ / $$ |\$$$$$$\ 980 | % $$ |$$ __$$ |$$ | $$ | \____$$\ 981 | % $$ |\$$$$$$$ |\$$$$$$$ |$$$$$$$ | 982 | % \__| \_______| \____$$ |\_______/ 983 | % $$\ $$ | 984 | % \$$$$$$ | 985 | % \______/ 986 | 987 | 988 | 989 | 990 | 991 | rpm_write_tag(T) when is_atom(T) -> 992 | case lists:keyfind(T,2,rpm_tags()) of 993 | {I,T} -> I; 994 | false -> 995 | case lists:keyfind(T,2,rpm_signature_tags()) of 996 | {I,T} -> I; 997 | false -> error({unknown_tag,T}) 998 | end 999 | end. 1000 | 1001 | rpm_signature_tags() -> 1002 | [ 1003 | {1000, signature_size}, 1004 | {1002, pgp_header}, 1005 | {1004, md5_header}, 1006 | {1007, signature_payloadsize}, 1007 | {1010, sha1_header}, 1008 | {1012, rsa_header} 1009 | ]. 1010 | 1011 | 1012 | rpm_tags() -> 1013 | [ 1014 | {62, header_signatures}, 1015 | {63, headerimmutable}, 1016 | {100, headeri18ntable}, 1017 | {1000, name}, % size for signature 1018 | {1001, version}, 1019 | {1002, release}, % pgp for signature 1020 | {1003, epoch}, 1021 | {1004, summary}, % md5 for signature 1022 | {1005, description}, 1023 | {1006, buildtime}, 1024 | {1007, buildhost}, % this is payloadsize for signature 1025 | {1008, installtime}, 1026 | {1009, size}, 1027 | {1010, distribution}, 1028 | {1011, vendor}, 1029 | {1012, gif}, 1030 | {1013, xpm}, 1031 | {1014, license}, 1032 | {1015, packager}, 1033 | {1016, group}, 1034 | {1017, changelog}, 1035 | {1018, source}, 1036 | {1019, patch}, 1037 | {1020, url}, 1038 | {1021, os}, 1039 | {1022, arch}, 1040 | {1023, preinstall}, 1041 | {1024, postinstall}, 1042 | {1025, preuninstall}, 1043 | {1026, postuninstall}, 1044 | {1027, old_filenames}, 1045 | {1028, filesizes}, 1046 | {1029, filestates}, 1047 | {1030, filemodes}, 1048 | {1031, fileuids}, 1049 | {1032, filegids}, 1050 | {1033, filerdevs}, 1051 | {1034, filemtimes}, 1052 | {1035, filedigests}, 1053 | {1036, filelinktos}, 1054 | {1037, fileflags}, 1055 | {1038, root}, 1056 | {1039, fileusername}, 1057 | {1040, filegroupname}, 1058 | {1041, exclude}, 1059 | {1042, exlusive}, 1060 | {1043, icon}, 1061 | {1044, sourcerpm}, 1062 | {1045, fileverifyflags}, 1063 | {1046, archivesize}, 1064 | {1047, providename}, 1065 | {1048, requireflags}, 1066 | {1049, requirename}, 1067 | {1050, requireversion}, 1068 | {1051, nosource}, 1069 | {1052, nopatch}, 1070 | {1053, conflictflags}, 1071 | {1054, conflictname}, 1072 | {1055, conflictversion}, 1073 | {1056, defaultprefix}, 1074 | {1057, buildroot}, 1075 | {1058, installprefix}, 1076 | {1059, excludearch}, 1077 | {1060, excludeos}, 1078 | {1061, exlusivearch}, 1079 | {1062, exlusiveos}, 1080 | {1063, autoreqprov}, 1081 | {1064, rpmversion}, 1082 | {1065, triggerscripts}, 1083 | {1066, triggername}, 1084 | {1067, triggerversion}, 1085 | {1068, triggerflags}, 1086 | {1069, triggerindex}, 1087 | {1079, verifyscript}, 1088 | {1080, changelogtime}, 1089 | {1081, changelogname}, 1090 | {1082, changelogtext}, 1091 | {1085, preinstall_prog}, 1092 | {1086, postinstall_prog}, 1093 | {1087, preuninstall_prog}, 1094 | {1088, postuninstall_prog}, 1095 | {1089, buildarch}, 1096 | {1090, obsoletename}, 1097 | {1092, triggerscript_prog}, 1098 | {1093, docdir}, 1099 | {1094, cookie}, 1100 | {1095, filedevices}, 1101 | {1096, fileinodes}, 1102 | {1097, filelangs}, 1103 | {1098, prefixes}, 1104 | {1112, provideflags}, 1105 | {1113, provideversion}, 1106 | {1114, obsoleteflags}, 1107 | {1115, obsoleteversion}, 1108 | {1116, dirindexes}, 1109 | {1117, basenames}, 1110 | {1118, dirnames}, 1111 | {1122, optflags}, 1112 | {1124, payloadformat}, 1113 | {1125, payloadcompressor}, 1114 | {1126, payloadflags}, 1115 | {1132, platform}, 1116 | {1140, filecolors}, 1117 | {1141, fileclass}, 1118 | {1142, classdict}, 1119 | {1143, filedependsx}, 1120 | {1144, filedependsn}, 1121 | {1145, filedependsdict}, 1122 | {5011, filedigestalgo} 1123 | ]. 1124 | 1125 | 1126 | 1127 | 1128 | 1129 | 1130 | % $$\ $$\ $$\ 1131 | % $$ | $$ | $$ | 1132 | % $$ | $$ | $$$$$$\ $$ | $$$$$$\ 1133 | % $$$$$$$$ |$$ __$$\ $$ |$$ __$$\ 1134 | % $$ __$$ |$$$$$$$$ |$$ |$$ / $$ | 1135 | % $$ | $$ |$$ ____|$$ |$$ | $$ | 1136 | % $$ | $$ |\$$$$$$$\ $$ |$$$$$$$ | 1137 | % \__| \__| \_______|\__|$$ ____/ 1138 | % $$ | 1139 | % $$ | 1140 | % \__| 1141 | 1142 | 1143 | help() -> 1144 | io:format(" 1145 | Usage: 1146 | epm [OPTIONS] [ARGS] ... 1147 | 1148 | Parameters: 1149 | [ARGS] ... Inputs to the source package type. For the 'dir' type, this is the files and directories you want to include in the package. For others, like 'gem', it specifies the packages to download and use as the gem input 1150 | 1151 | Options: 1152 | --gpg user@host.local name of GPG key owner to use for signing rpm package 1153 | --rsa path_to_key path of RSA private key for signing APK 1154 | -t OUTPUT_TYPE the type of package you want to create (deb, rpm) 1155 | -s INPUT_TYPE the package type to use as input (dir only supported) 1156 | -p, --package OUTPUT The package file path to output. 1157 | -f, --force Force output even if it will overwrite an existing file (default: false) 1158 | -n, --name NAME The name to give to the package 1159 | --verbose Enable verbose output 1160 | --debug Enable debug output 1161 | --gpg-program FILE Use specific gpg program 1162 | -v, --version VERSION The version to give to the package (default: 1.0) 1163 | --iteration ITERATION The iteration to give to the package. RPM calls this the 'release'. FreeBSD calls it 'PORTREVISION'. Debian calls this 'debian_revision' 1164 | --epoch EPOCH The epoch value for this package. RPM and Debian calls this 'epoch'. FreeBSD calls this 'PORTEPOCH' 1165 | --license LICENSE (optional) license name for this package 1166 | --vendor VENDOR (optional) vendor name for this package 1167 | --category CATEGORY (optional) category this package belongs to 1168 | -d, --depends DEPENDENCY A dependency. This flag can be specified multiple times. Value is usually in the form of: -d 'name' or -d 'name > version' 1169 | --provides PROVIDES What this package provides (usually a name). This flag can be specified multiple times. 1170 | --conflicts CONFLICTS Other packages/versions this package conflicts with. This flag can specified multiple times. 1171 | --suggests SUGGESTS Other packages/versions this package suggest to install. This flag can specified multiple times. 1172 | --replaces REPLACES Other packages/versions this package replaces. This flag can be specified multiple times. 1173 | --provides PROVIDES Virtual packages this package provides 1174 | --config-files CONFIG_FILES Mark a file in the package as being a config file. This uses 'conffiles' in debs and %config in rpm. If you have multiple files to mark as configuration files, specify this flag multiple times. 1175 | -a, --architecture ARCHITECTURE The architecture name. Usually matches 'uname -m'. For automatic values, you can use '-a all' or '-a native'. These two strings will be translated into the correct value for your platform and target package type. 1176 | -m, --maintainer MAINTAINER The maintainer of this package. (default: \"\") 1177 | --description DESCRIPTION Add a description for this package. You can include ' 1178 | ' sequences to indicate newline breaks. (default: \"no description\") 1179 | --url URI Add a url for this package. (default: \"http://example.com/no-uri-given\") 1180 | --post-install FILE a script to be run after package installation 1181 | --pre-install FILE a script to be run before package installation 1182 | --post-uninstall FILE a script to be run after package removal 1183 | --pre-uninstall FILE a script to be run before package removal 1184 | 1185 | "). 1186 | 1187 | 1188 | 1189 | 1190 | 1191 | 1192 | set_scriptlet_env(Name, Version, Script) when is_binary(Script) -> 1193 | << 1194 | "RPM_PACKAGE_NAME=", Name/binary, 10, 1195 | "RPM_PACKAGE_VERSION=", Version/binary, 10, 1196 | Script/binary 1197 | >>; 1198 | set_scriptlet_env(_, _, Script) -> Script. 1199 | 1200 | 1201 | -------------------------------------------------------------------------------- /postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | systemctl enable ssh-proxy 4 | 5 | -------------------------------------------------------------------------------- /ssh-proxy.erl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% 3 | %%! 4 | 5 | -mode(compile). 6 | -include_lib("eldap/include/eldap.hrl"). 7 | 8 | 9 | -define(TIMEOUT, 60000). 10 | -record(cli, { 11 | user_addr, 12 | private_key_path, 13 | remote_spec, 14 | l_conn, 15 | l_chan, 16 | r_conn, 17 | r_chan, 18 | f_chan 19 | }). 20 | 21 | % key_cb callbacks 22 | -export([user_key/2, is_auth_key/3, is_host_key/4, add_host_key/3, host_key/2]). 23 | 24 | 25 | -export([init/1, handle_ssh_msg/2, handle_msg/2, terminate/2]). 26 | 27 | main(Args) -> 28 | Opts = parse_args(Args, #{ 29 | server_dir => "priv/server", 30 | port => 2022, 31 | private_key_path => "priv/auth", 32 | user_keys => "priv/users" 33 | }), 34 | crypto:start(), 35 | ssh:start(), 36 | error_logger:tty(true), 37 | ServerDir = maps:get(server_dir, Opts), 38 | Port = maps:get(port, Opts), 39 | case Opts of 40 | #{ldap := Ldap} -> 41 | {ok,_} = application:ensure_all_started(ssl), 42 | {ok,_} = application:ensure_all_started(eldap), 43 | case ldap_fetch_keys(Ldap) of 44 | {ok, _} -> 45 | io:format("Ldap server is functioning\n"); 46 | {error, LdapError} -> 47 | io:format("Ldap server is configured but not working: ~p\n",[LdapError]) 48 | end; 49 | _ -> 50 | ok 51 | end, 52 | 53 | Options = [ 54 | {auth_methods,"publickey"}, 55 | {password, "defaultpassword"}, 56 | % {user_interaction,false}, 57 | % {io_cb, ssh_no_io}, 58 | {system_dir, ServerDir}, 59 | {key_cb, {?MODULE, [Opts]}}, 60 | {ssh_cli, {?MODULE, [Opts]}}, 61 | {connectfun, fun on_connect/3}, 62 | {disconnectfun, fun on_disconnect/1}, 63 | {ssh_msg_debug_fun, fun(A,B,C,D) -> io:format("~p ~p ~p ~p\n",[A,B,C,D]) end} 64 | ], 65 | {ok, Daemon} = ssh:daemon(any, Port, Options), 66 | {ok, Info} = ssh:daemon_info(Daemon), 67 | io:format("Listening on port ~p\n", [proplists:get_value(port, Info)]), 68 | receive 69 | _ -> ok 70 | end. 71 | 72 | 73 | 74 | parse_args([], Opts) -> 75 | Opts; 76 | parse_args(["-h"|_], _) -> 77 | io:format( 78 | " -i private_ssh_dir - directory with id_rsa key, used for authentication. By default priv/auth\n" 79 | " -u users_keys_directory - directory with user keys, used for authentication. By default priv/users\n" 80 | " -t private_daemon_dir - private daemon dir with his host key. By default priv/server\n" 81 | " -p port - port to listen. By default 2022\n" 82 | " -c config_file - config file in erlang format\n" 83 | " -l ldaps://password@server:port/bind-dn/base - ldap search for ssh keys\n" 84 | ), 85 | init:stop(2); 86 | parse_args(["-i", PrivateKey|Args], Opts) -> 87 | parse_args(Args, Opts#{private_key_path => PrivateKey}); 88 | parse_args(["-u", UsersKeysDir|Args], Opts) -> 89 | parse_args(Args, Opts#{user_keys => UsersKeysDir}); 90 | parse_args(["-t", TempDir|Args], Opts) -> 91 | parse_args(Args, Opts#{server_dir => TempDir}); 92 | parse_args(["-p", Port|Args], Opts) -> 93 | parse_args(Args, Opts#{port => list_to_integer(Port)}); 94 | parse_args(["-l", Ldap|Args], Opts) -> 95 | case parse_ldap(Ldap) of 96 | #{} -> 97 | parse_args(Args, Opts#{ldap => Ldap}); 98 | _ -> 99 | io:format("Error reading ldap address: ~s\n",[Ldap]), 100 | init:stop(5) 101 | end; 102 | parse_args(["-c", ConfigFile|Args], Opts) -> 103 | case file:consult(ConfigFile) of 104 | {ok, Env} -> 105 | parse_args(Args, maps:merge(Opts, maps:from_list(Env))); 106 | {error, E} -> 107 | io:format("Error reading config file ~s: ~p\n",[ConfigFile, E]), 108 | init:stop(4) 109 | end; 110 | parse_args([Opt|_], _Opts) -> 111 | io:format("Unknown key: ~s\n", [Opt]), 112 | init:stop(3). 113 | 114 | 115 | 116 | parse_ldap(URL) -> 117 | case http_uri:parse(URL) of 118 | {ok, {Proto,Password,Server,Port,Path,_Query}} when Proto == ldap orelse Proto == ldaps -> 119 | case string:tokens(Path,"/") of 120 | [BindDn, Base] -> 121 | SSL = Proto == ldaps, 122 | #{ssl => SSL, password => Password, host => Server, port => Port, bind_dn => BindDn, base => Base}; 123 | _ -> 124 | {error, invalid_path} 125 | end; 126 | _ -> 127 | {error, invalid_url} 128 | end. 129 | 130 | 131 | ldap_fetch_keys(URL) -> 132 | #{host := Host, port := Port, ssl := SSL, bind_dn := BindDn, password := Password, base := Base} = parse_ldap(URL), 133 | case eldap:open([Host], [{port,Port},{ssl,SSL}]) of 134 | {ok,Handle} -> 135 | eldap:simple_bind(Handle,BindDn,Password), 136 | 137 | Filter = eldap:'and'([ 138 | eldap:present("ipaSshPubKey"), 139 | eldap:present("uid") 140 | ]), 141 | case eldap:search(Handle,[{base,Base},{filter,Filter},{attributes,["ipaSshPubKey","uid"]}]) of 142 | {error, FetchError} -> 143 | {error, FetchError}; 144 | {ok, Reply} -> 145 | eldap:close(Handle), 146 | #eldap_search_result{entries = Entries} = Reply, 147 | Keys = lists:flatmap(fun(#eldap_entry{attributes = A}) -> 148 | SshKeys = [_|_] = proplists:get_value("ipaSshPubKey", A), 149 | [Uid] = proplists:get_value("uid",A), 150 | lists:flatmap(fun(SshKey) -> 151 | case binary:split(iolist_to_binary(SshKey),<<" ">>, [global]) of 152 | [<<"ssh-",_/binary>>, Key64 |_] -> 153 | [{iolist_to_binary(Key64),iolist_to_binary(Uid)}]; 154 | _ -> 155 | [] 156 | end 157 | end, SshKeys) 158 | end, Entries), 159 | {ok, maps:from_list(Keys)} 160 | end; 161 | {error, ConnectError} -> 162 | {error, ConnectError} 163 | end. 164 | 165 | 166 | 167 | is_auth_key(PublicKey,Username,Opts0) -> 168 | try is_auth_key0(PublicKey,Username,Opts0) 169 | catch 170 | C:E -> 171 | ST = erlang:get_stacktrace(), 172 | io:format("~p:~p in\n~p\n", [C,E,ST]), 173 | false 174 | end. 175 | 176 | 177 | is_auth_key0(PublicKey,Username,Opts0) -> 178 | [#{} = Opts] = proplists:get_value(key_cb_private, Opts0), 179 | SshKey = (catch public_key:ssh_encode(PublicKey,ssh2_pubkey)), 180 | UsersKeysDir = maps:get(user_keys, Opts), 181 | KeyPaths = filelib:wildcard(UsersKeysDir++"/*"), 182 | % io:format("key paths: ~p\n", [KeyPaths]), 183 | Ldap = maps:get(ldap, Opts, undefined), 184 | case search_key_on_disk_or_ldap(SshKey, KeyPaths, Ldap) of 185 | {ok, Name} -> 186 | case get(client_public_key_name) of 187 | undefined -> 188 | io:format("User ~s logged in to ~s in ~p\n", [Name, Username, self()]), 189 | put(client_public_key_name, Name), 190 | put(client_public_key, PublicKey); 191 | _ -> 192 | ok 193 | end, 194 | true; 195 | undefined -> 196 | io:format("Unknown attemp to login to ~s with key: ~s\n", [Username, base64:encode(SshKey)]), 197 | false 198 | end. 199 | 200 | 201 | search_key_on_disk_or_ldap(SshKey, KeyPaths, Ldap) -> 202 | case search_key(SshKey, KeyPaths) of 203 | undefined when Ldap =/= undefined -> 204 | search_key_in_ldap(SshKey, Ldap); 205 | {ok, Name} -> 206 | {ok, Name} 207 | end. 208 | 209 | 210 | 211 | search_key(_, []) -> 212 | undefined; 213 | 214 | search_key(SshKey, [Path|List]) -> 215 | case file:read_file(Path) of 216 | {error, _} -> 217 | io:format("Unreadable key file ~s\n", [Path]), 218 | search_key(SshKey, List); 219 | {ok, Text} -> 220 | case binary:split(Text, <<" ">>, [global]) of 221 | [<<"ssh-",_/binary>>, Key64 |_] -> 222 | case base64:decode(Key64) of 223 | SshKey -> {ok, filename:basename(Path)}; 224 | _ -> search_key(SshKey, List) 225 | end; 226 | _ -> 227 | io:format("Unvalid key file ~s\n", [Path]), 228 | search_key(SshKey, List) 229 | end 230 | end. 231 | 232 | 233 | 234 | search_key_in_ldap(SshKey, Ldap) -> 235 | case ldap_fetch_keys(Ldap) of 236 | {ok, Keys} -> 237 | case maps:get(base64:encode(SshKey), Keys, undefined) of 238 | undefined -> 239 | io:format("Lookup '~p' in\n~p\n\n",[base64:encode(SshKey),Keys]), 240 | undefined; 241 | Name -> 242 | {ok, Name} 243 | end; 244 | {error, _} -> 245 | undefined 246 | end. 247 | 248 | 249 | 250 | user_key(A,B) -> 251 | io:format("user_key: ~p ~p\n",[A,B]), 252 | {ok,crypto:strong_rand_bytes(16)}. 253 | 254 | 255 | add_host_key(A,B,C) -> 256 | io:format("add_host_key: ~p ~p ~p\n",[A,B,C]), 257 | ok. 258 | 259 | is_host_key(A,B,C,D) -> 260 | io:format("is_host_key: ~p ~p ~p ~p\n",[A,B,C,D]), 261 | true. 262 | 263 | host_key(Algo,Opts) -> 264 | try ssh_file:host_key(Algo, Opts) of 265 | {ok, Bin} -> 266 | % error_logger:info_msg("~s host_key requested\n",[Algo]), 267 | {ok, Bin}; 268 | {error, E} -> 269 | % error_logger:info_msg("~s host_key error: ~p\n",[Algo, E]), 270 | {error, E} 271 | catch 272 | C:E -> 273 | error_logger:info_msg("~s host_key ~p: ~p\n~p",[Algo, C, E, erlang:get_stacktrace()]), 274 | {error, E} 275 | end. 276 | 277 | 278 | 279 | on_connect(_Username,_B,_C) -> 280 | % io:format("~p on_connect: ~p ~p ~p\n",[self(), Username,B,C]), 281 | ok. 282 | 283 | on_disconnect(_A) -> 284 | % io:format("~p on_disconnect: ~p\n",[self(), A]), 285 | ok. 286 | 287 | 288 | 289 | 290 | 291 | init([#{} = Opts]) -> 292 | % io:format("INIT: ~p\n", [_Args]), 293 | PrivateKeyPath = maps:get(private_key_path, Opts), 294 | {ok, #cli{private_key_path = PrivateKeyPath}}. 295 | 296 | 297 | 298 | handle_ssh_msg({ssh_cm, Conn, Msg}, #cli{} = State) -> 299 | % io:format("sshmsg(~p,~p,~p) ~300p\n",[Conn, State#cli.l_conn, State#cli.r_conn, Msg]), 300 | handle_ssh_msg2({ssh_cm, Conn, Msg}, State). 301 | 302 | 303 | handle_msg(Msg, #cli{} = State) -> 304 | handle_msg2(Msg, State). 305 | 306 | 307 | 308 | 309 | 310 | 311 | handle_ssh_msg2({ssh_cm, Conn, {data, _, Type, Data}}, #cli{r_conn = Conn, l_conn = Local, l_chan = LocChan} = State) -> 312 | ssh_connection:send(Local, LocChan, Type, Data, ?TIMEOUT), 313 | {ok, State}; 314 | 315 | handle_ssh_msg2({ssh_cm, _, {data, _, Type, Data}}, #cli{r_conn = Conn, r_chan = ChannelId, f_chan = undefined} = State) -> 316 | ssh_connection:send(Conn, ChannelId, Type, Data, ?TIMEOUT), 317 | {ok, State}; 318 | 319 | handle_ssh_msg2({ssh_cm, _, {data, _, Type, Data}}, #cli{r_conn = Conn, f_chan = ChannelId} = State) -> 320 | ssh_connection:send(Conn, ChannelId, Type, Data, ?TIMEOUT), 321 | {ok, State}; 322 | 323 | handle_ssh_msg2({ssh_cm, Conn, {eof,_}}, #cli{r_conn = Conn, l_conn = Local, l_chan = LocChan} = State) -> 324 | ssh_connection:send_eof(Local, LocChan), 325 | {ok, State}; 326 | 327 | handle_ssh_msg2({ssh_cm, Conn, {exit_status,_,Status}}, #cli{r_conn = Conn, l_conn = Local, l_chan = LocChan} = State) -> 328 | ssh_connection:exit_status(Local, LocChan, Status), 329 | {ok, State}; 330 | 331 | handle_ssh_msg2({ssh_cm, Conn, {closed, _ChannelId}}, #cli{r_conn = Conn, l_chan = LocChan} = State) -> 332 | {stop, LocChan, State}; 333 | 334 | handle_ssh_msg2({ssh_cm, Conn, Msg}, #cli{r_conn = Conn} = State) -> 335 | io:format("REM2 ~p\n",[Msg]), 336 | {ok, State}; 337 | 338 | 339 | handle_ssh_msg2({ssh_cm, _, {pty, _Chan, _, Request}}, #cli{r_conn = Conn, r_chan = ChannelId} = State) -> 340 | {TermName, Width, Height, PixWidth, PixHeight, Modes} = Request, 341 | % Strange workaround 342 | FilteredModes = lists:flatmap(fun 343 | ({41,V}) -> [{imaxbel,V}]; 344 | ({K,V}) when is_atom(K) -> [{K,V}]; 345 | (_) -> [] 346 | end, Modes), 347 | PtyOptions = [{term,TermName},{width,Width},{height,Height}, 348 | {pixel_width,PixWidth},{pixel_height,PixHeight},{pty_opts,FilteredModes}], 349 | ssh_connection:ptty_alloc(Conn, ChannelId, PtyOptions, ?TIMEOUT), 350 | {ok, State}; 351 | 352 | handle_ssh_msg2({ssh_cm, _, {env, _Chan, _, Var, Value}}, #cli{r_conn = Conn, r_chan = ChannelId} = State) -> 353 | ssh_connection:setenv(Conn, ChannelId, binary_to_list(Var), binary_to_list(Value), ?TIMEOUT), 354 | {ok, State}; 355 | 356 | handle_ssh_msg2({ssh_cm, Local, {shell,_LocChan,_}}, #cli{l_conn = Local, r_conn = Conn, r_chan = ChannelId} = State) -> 357 | ssh_connection:shell(Conn, ChannelId), 358 | {ok, State}; 359 | 360 | handle_ssh_msg2({ssh_cm, Local, {exec,_LocChan,_,Command}}, #cli{l_conn = Local, r_conn = Conn, r_chan = ChannelId} = State) -> 361 | ssh_connection:exec(Conn, ChannelId, Command, ?TIMEOUT), 362 | {ok, State}; 363 | 364 | handle_ssh_msg2({ssh_cm, Local, {eof,_LocChan}}, #cli{l_conn = Local, r_conn = Conn, r_chan = ChannelId} = State) -> 365 | ssh_connection:send_eof(Conn, ChannelId), 366 | {ok, State}; 367 | 368 | handle_ssh_msg2({ssh_cm, Local, Msg}, #cli{l_conn = Local} = State) -> 369 | io:format("LOC ~p\n",[Msg]), 370 | {ok, State}; 371 | 372 | handle_ssh_msg2(Msg, State) -> 373 | io:format("Unknown ~p\n",[Msg]), 374 | {ok, State}. 375 | 376 | 377 | 378 | 379 | 380 | 381 | handle_msg2({ssh_channel_up, LocChan, Local}, #cli{private_key_path = PrivateKeyPath} = State) -> 382 | {[User, Host, Port], SshForward} = parse_proxy_request(Local), 383 | % io:format("~p Open proxy to ~s@~s\n", [self(), User,Host]), 384 | case 385 | ssh:connect(Host, Port, [ 386 | {user, User}, 387 | {user_dir, PrivateKeyPath}, 388 | {silently_accept_hosts, true}, 389 | {user_interaction, false}, 390 | {quiet_mode, true} 391 | ]) 392 | of 393 | {ok, Conn} -> 394 | {ok, ChannelId} = ssh_connection:session_channel(Conn, ?TIMEOUT), 395 | case SshForward of 396 | undefined -> 397 | {ok, State#cli{r_conn = Conn, r_chan = ChannelId, l_conn = Local, l_chan = LocChan}}; 398 | [ForwardHost, ForwardPort] -> 399 | {open, ForwardChannel} = direct_tcpip(Conn, 400 | {<<"127.0.0.1">>, ForwardPort}, 401 | {ForwardHost, ForwardPort} 402 | ), 403 | {ok, State#cli{r_conn = Conn, r_chan = ChannelId, f_chan = ForwardChannel, l_conn = Local, l_chan = LocChan}} 404 | end; 405 | {error, Error} -> 406 | ssh_connection:send(Local, LocChan, 1, [Error,"\n"]), 407 | {stop, LocChan, State} 408 | end; 409 | 410 | 411 | handle_msg2(Msg, #cli{} = State) -> 412 | io:format("Msg ~p ~p\n",[Msg,State]), 413 | {ok, State}. 414 | 415 | terminate(_,_) -> ok. 416 | 417 | 418 | %% 419 | %% 420 | parse_proxy_request(SshConnection) -> 421 | Request = proplists:get_value(user, 422 | ssh:connection_info(SshConnection, [user, peer]) 423 | ), 424 | [SshHost | SshForward] = string:tokens(Request, [$~]), 425 | {parse_user_host_port(SshHost), parse_host_port(SshForward)}. 426 | 427 | parse_host_port([]) -> 428 | undefined; 429 | parse_host_port([Spec]) -> 430 | [Host, Port] = string:tokens(Spec, [$/]), 431 | [erlang:list_to_binary(Host), list_to_integer(Port)]. 432 | 433 | parse_user_host_port(Spec) -> 434 | case string:tokens(Spec, [$/]) of 435 | [User, Host, Port] -> 436 | [User, Host, list_to_integer(Port)]; 437 | [Host] -> 438 | ["root", Host, 22]; 439 | [User, Host] -> 440 | try list_to_integer(Host) of 441 | Port -> ["root", User, Port] 442 | catch 443 | _:_ -> [User, Host, 22] 444 | end 445 | end. 446 | 447 | %% 448 | %% 449 | direct_tcpip(Conn, From, To) -> 450 | {OrigHost, OrigPort} = From, 451 | {RemoteHost, RemotePort} = To, 452 | 453 | RemoteLen = byte_size(RemoteHost), 454 | OrigLen = byte_size(OrigHost), 455 | 456 | Msg = << 457 | RemoteLen:32, 458 | RemoteHost/binary, 459 | RemotePort:32, 460 | OrigLen:32, 461 | OrigHost/binary, 462 | OrigPort:32 463 | >>, 464 | 465 | ssh_connection_handler:open_channel( 466 | Conn, 467 | "direct-tcpip", 468 | Msg, 469 | 1024 * 1024, 470 | 32 * 1024, 471 | infinity 472 | ). 473 | -------------------------------------------------------------------------------- /ssh-proxy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH proxy 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | Environment=HOME=/var/lib/ssh-proxy 8 | Environment=PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin 9 | Environment=LANG=C 10 | Type=notify 11 | User=root 12 | Group=root 13 | LimitNOFILE=102400 14 | ExecStartPre=/bin/mkdir -p /var/lib/ssh-proxy 15 | ExecStart=/usr/sbin/ssh-proxy.erl -c /etc/ssh-proxy/ssh-proxy.conf 16 | Restart=on-failure 17 | TimeoutStartSec=300s 18 | #WatchdogSec=120 19 | WorkingDirectory=/var/lib/ssh-proxy 20 | NotifyAccess=main 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | --------------------------------------------------------------------------------