├── .gitattributes ├── media └── icon.png ├── uninstall.sh ├── scripts └── vpn-toggle.sh ├── src ├── usr │ ├── share │ │ └── polkit-1 │ │ │ └── actions │ │ │ └── vpn-manager.policy │ └── local │ │ └── bin │ │ ├── __vpn_connect │ │ └── __vpn_manager └── etc │ ├── NetworkManager │ └── dispatcher.d │ │ └── vpn-manager-refresh │ └── keyd │ └── vpn-manager.conf ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── patches └── custom-dns-resolver.patch ├── install-steamos.sh ├── .gitignore ├── CODE_OF_CONDUCT.md ├── README.md └── install.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kira-NT/outline-cli/HEAD/media/icon.png -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Uninstall the Outline Client CLI. 4 | 5 | ./install.sh -u 6 | -------------------------------------------------------------------------------- /scripts/vpn-toggle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | zenity --info --title "Outline" --text "$(pkexec /usr/local/bin/__vpn_manager connect)" --width 300 2> /dev/null 4 | pkexec /usr/local/bin/__vpn_manager disconnect > /dev/null 2>& 1 5 | -------------------------------------------------------------------------------- /src/usr/share/polkit-1/actions/vpn-manager.policy: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Kira-NT 8 | https://github.com/Kira-NT/outline-cli 9 | network-wireless 10 | 11 | 12 | VPN 13 | Authentication is required to change or query the current VPN status 14 | 15 | auth_admin 16 | yes 17 | yes 18 | 19 | /usr/local/bin/__vpn_manager 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [ push, pull_request, workflow_dispatch ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Install `outline-cli` 13 | run: sudo ./install.sh -y 14 | 15 | - name: Ensure `outline-cli` is accessible 16 | run: | 17 | sudo vpn --help && \ 18 | sudo vpn ls && \ 19 | sudo vpn add "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTo1UkVmeFRqbHR6Mkw@outline-server.example.com:17178" && \ 20 | sudo vpn ls && \ 21 | sudo vpn add "ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTo1UkVmeFRqbHR6Mkw@127.0.0.1:8000" localhost && \ 22 | sudo vpn ls && \ 23 | sudo vpn rm 1 && \ 24 | sudo vpn ls 25 | 26 | - name: Uninstall `outline-cli` 27 | run: sudo ./uninstall.sh 28 | 29 | - name: Ensure `outline-cli` is no longer accessible 30 | run: "! sudo vpn --help" 31 | -------------------------------------------------------------------------------- /src/etc/NetworkManager/dispatcher.d/vpn-manager-refresh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Refresh a connection to a Shadowsocks server 4 | # in response to network events. 5 | 6 | # We only need to act when a wired or wireless 7 | # connection is activated or deactivated. 8 | # Exit early for all other types of network events. 9 | [ "${2}" = "up" ] || [ "${2}" = "down" ] || exit 0 10 | grep -sqE "type=(eth|wifi)" "${CONNECTION_FILENAME}" || exit 0 11 | 12 | case "${2}" in 13 | # If the network went up, resume a suspended 14 | # connection to a Shadowsocks server, if any. 15 | up) 16 | /usr/local/bin/__vpn_manager status -q 17 | if [ $? -ne 1 ]; then 18 | /usr/local/bin/__vpn_manager connect -q & 19 | fi 20 | ;; 21 | 22 | # If the network went down, suspend a connection 23 | # to a Shadowsocks server, if any, to prevent 24 | # the system from reporting an interface with 25 | # no internet access. 26 | down) /usr/local/bin/__vpn_manager disconnect -s -q & ;; 27 | esac 28 | 29 | # Always return a success status code 30 | # to avoid confusing the NetworkManager. 31 | exit 0 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Kira NT 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 | -------------------------------------------------------------------------------- /patches/custom-dns-resolver.patch: -------------------------------------------------------------------------------- 1 | From 0dee551ff51ea6721858ee5fe4d58b48b6b1827a Mon Sep 17 00:00:00 2001 2 | From: Kira-NT 3 | Date: Sun, 16 Mar 2024 17:28:49 +0000 4 | Subject: [PATCH] Added a way to specify a custom DNS resolver 5 | 6 | See Jigsaw-Code/outline-apps#568 7 | --- 8 | x/examples/outline-cli/main.go | 8 ++++++++ 9 | 1 file changed, 8 insertions(+) 10 | 11 | diff --git a/x/examples/outline-cli/main.go b/x/examples/outline-cli/main.go 12 | index 1be0096..82501c4 100644 13 | --- a/x/examples/outline-cli/main.go 14 | +++ b/x/examples/outline-cli/main.go 15 | @@ -19,6 +19,7 @@ import ( 16 | "fmt" 17 | "io" 18 | "log" 19 | + "net/url" 20 | "os" 21 | ) 22 | 23 | @@ -49,6 +50,13 @@ func main() { 24 | } 25 | flag.Parse() 26 | 27 | + if transportUrl, _ := url.Parse(*app.TransportConfig); transportUrl != nil { 28 | + if dns := transportUrl.Query().Get("dns"); dns != "" { 29 | + app.RoutingConfig.DNSServerIP = dns 30 | + } 31 | + } 32 | + logging.Info.Printf("updated system DNS resolver: %v\n", app.RoutingConfig.DNSServerIP) 33 | + 34 | if err := app.Run(); err != nil { 35 | logging.Err.Printf("%v\n", err) 36 | } 37 | -- 38 | 2.44.0 39 | -------------------------------------------------------------------------------- /install-steamos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | error() { 4 | echo "${0}: ${1}" >& 2 5 | return 1 6 | } 7 | 8 | is_root() { 9 | [ "$(id -u)" -eq 0 ] 10 | } 11 | 12 | is_steamos() { 13 | [ -f "/etc/os-release" ] && 14 | . "/etc/os-release" && 15 | [ "${ID}" = "steamos" ] 16 | } 17 | 18 | is_installed() { 19 | [ -f "/usr/local/bin/__vpn_connect" ] && [ -x "/usr/local/bin/__vpn_connect" ] && 20 | [ -f "/usr/local/bin/__vpn_manager" ] && [ -x "/usr/local/bin/__vpn_manager" ] && 21 | [ -f "/usr/share/polkit-1/actions/vpn-manager.policy" ] 22 | } 23 | 24 | install_local() { 25 | "${@}" 26 | } 27 | 28 | install_remote() { 29 | curl -Ls "https://github.com/Kira-NT/outline-cli/blob/master/install.sh?raw=true" | sh -s -- "${@}" 30 | } 31 | 32 | install() { 33 | local install_filename="$(dirname "$(realpath -m "${0}")")/install.sh" 34 | local expected_header="#!/bin/sh Install the Outline Client CLI." 35 | local header="$(sed -n '1,3N;N;s/\n#//g;1,3p;q' "${install_filename}" 2> /dev/null)" 36 | if [ -x "${install_filename}" ] && [ "${header}" = "${expected_header}" ]; then 37 | install_local "${install_filename}" "${@}" <& 1 38 | else 39 | install_remote "${@}" <& 1 40 | fi 41 | } 42 | 43 | install_steamos_packages() { 44 | steamos-readonly disable 2> /dev/null 45 | pacman-key --init 2> /dev/null 46 | pacman-key --populate holo 2> /dev/null 47 | pacman -S --noconfirm git base-devel linux-neptune-headers glibc linux-api-headers 2> /dev/null 48 | } 49 | 50 | main() { 51 | is_installed && return 52 | is_steamos || error "cannot perform the installation: ${ID:-"unknown"}: Distribution is not supported" || return 53 | is_root || error "cannot perform the installation: Permission denied" || return 54 | 55 | install_steamos_packages 56 | install "${@}" 57 | } 58 | 59 | main "${@}" 60 | -------------------------------------------------------------------------------- /src/etc/keyd/vpn-manager.conf: -------------------------------------------------------------------------------- 1 | [ids] 2 | * 3 | 4 | # Configured binds: 5 | # 6 | # Ctrl+Super+v = Toggle the current connection 7 | # Ctrl+Super+v+- = Disconnect from the current server 8 | # Ctrl+Super+v+= = Connect to a server you were last connected to 9 | # Ctrl+Super+v+0 = Show the current connection status 10 | # Ctrl+Super+v+1 = Connect to a server using the 1st access key 11 | # Ctrl+Super+v+2 = Connect to a server using the 2nd access key 12 | # Ctrl+Super+v+3 = Connect to a server using the 3rd access key 13 | # Ctrl+Super+v+4 = Connect to a server using the 4th access key 14 | # Ctrl+Super+v+5 = Connect to a server using the 5th access key 15 | # Ctrl+Super+v+6 = Connect to a server using the 6th access key 16 | # Ctrl+Super+v+7 = Connect to a server using the 7th access key 17 | # Ctrl+Super+v+8 = Connect to a server using the 8th access key 18 | # Ctrl+Super+v+9 = Connect to a server using the 9th access key 19 | # 20 | # To remove Ctrl from all the binds, thereby reducing the number of 21 | # keys needed to be pressed, simply delete "control+" from the line 22 | # below this comment section. 23 | # 24 | # This is not done by default because it will interfere with 25 | # the `Super+v` hotkey (which displays the clipboard history) often 26 | # present on some desktops. But if you don't use this (good!), then 27 | # there's nothing to worry about. 28 | 29 | [control+meta] 30 | v = overload(vpn_manager, command(/usr/local/bin/__vpn_manager toggle -n &)) 31 | 32 | [vpn_manager] 33 | 1 = command(/usr/local/bin/__vpn_manager connect 1 -n &) 34 | 2 = command(/usr/local/bin/__vpn_manager connect 2 -n &) 35 | 3 = command(/usr/local/bin/__vpn_manager connect 3 -n &) 36 | 4 = command(/usr/local/bin/__vpn_manager connect 4 -n &) 37 | 5 = command(/usr/local/bin/__vpn_manager connect 5 -n &) 38 | 6 = command(/usr/local/bin/__vpn_manager connect 6 -n &) 39 | 7 = command(/usr/local/bin/__vpn_manager connect 7 -n &) 40 | 8 = command(/usr/local/bin/__vpn_manager connect 8 -n &) 41 | 9 = command(/usr/local/bin/__vpn_manager connect 9 -n &) 42 | 0 = command(/usr/local/bin/__vpn_manager status -n &) 43 | - = command(/usr/local/bin/__vpn_manager disconnect -n &) 44 | = = command(/usr/local/bin/__vpn_manager connect -n &) 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | $tf/ 2 | **/*.DesktopClient/GeneratedArtifacts 3 | **/*.DesktopClient/ModelManifest.xml 4 | **/*.HTMLClient/GeneratedArtifacts 5 | **/*.Server/GeneratedArtifacts 6 | **/*.Server/ModelManifest.xml 7 | **/packages/* 8 | *.[Cc]ache 9 | *.[Pp]ublish.xml 10 | *.[Rr]e[Ss]harper 11 | *.aps 12 | *.azurePubxml 13 | *.bim.layout 14 | *.bim_*.settings 15 | *.build.csdef 16 | *.cachefile 17 | *.dbmdl 18 | *.dbproj.schemaview 19 | *.dotCover 20 | *.DotSettings.user 21 | *.GhostDoc.xml 22 | *.gpState 23 | *.ilk 24 | *.jfm 25 | *.ldf 26 | *.log 27 | *.mdf 28 | *.meta 29 | *.mm.* 30 | *.ncb 31 | *.nuget.props 32 | *.nuget.targets 33 | *.nupkg 34 | *.nuspec 35 | *.obj 36 | *.opendb 37 | *.opensdf 38 | *.opt 39 | *.pch 40 | *.pdb 41 | *.pfx 42 | *.pgc 43 | *.pgd 44 | *.pidb 45 | *.plg 46 | *.psess 47 | *.publishproj 48 | *.publishsettings 49 | *.pubxml 50 | *.pyc 51 | *.rdl.data 52 | *.rsp 53 | *.sap 54 | *.sbr 55 | *.scc 56 | *.sdf 57 | *.sln.docstates 58 | *.sln.iml 59 | *.suo 60 | *.suppress 61 | *.svclog 62 | *.tlb 63 | *.tlh 64 | *.tli 65 | *.tmp 66 | *.tmp_proj 67 | *.user 68 | *.userosscache 69 | *.userprefs 70 | *.VC.db 71 | *.VC.VC.opendb 72 | *.VisualState.xml 73 | *.vsp 74 | *.vspscc 75 | *.vspx 76 | *.vssscc 77 | *_i.c 78 | *_i.h 79 | *_p.c 80 | *~ 81 | .*crunch*.local.xml 82 | .builds 83 | .cr/ 84 | .env 85 | .fake/ 86 | .gradle/ 87 | .idea/ 88 | .JustCode 89 | .ntvs_analysis.dat 90 | .paket/paket.exe 91 | .sass-cache/ 92 | .vs/ 93 | /node_modules 94 | /run 95 | /wwwroot/dist/ 96 | [Bb]in/ 97 | [Bb]uild[Ll]og.* 98 | [Dd]ebug/ 99 | [Dd]ebugPS/ 100 | [Dd]ebugPublic/ 101 | [Ee]xpress/ 102 | [Ll]og/ 103 | [Ll]ogs/ 104 | [Oo]bj/ 105 | [Pp]ublish/ 106 | [Rr]elease/ 107 | [Rr]eleasePS/ 108 | [Rr]eleases/ 109 | [Tt]est[Rr]esult*/ 110 | __pycache__/ 111 | _Chutzpah* 112 | _NCrunch_* 113 | _pkginfo.txt 114 | _Pvt_Extensions 115 | _ReSharper*/ 116 | _TeamCity* 117 | _UpgradeReport_Files/ 118 | ~$* 119 | ApplicationInsights.config 120 | AppPackages/ 121 | appsettings.*.json 122 | artifacts/ 123 | AutoTest.Net/ 124 | Backup*/ 125 | bin/ 126 | Bin/ 127 | bld/ 128 | build/ 129 | BundleArtifacts/ 130 | ClientBin/ 131 | csx/ 132 | dist/ 133 | dlldata.c 134 | DocProject/buildhelp/ 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.HxT 140 | DocProject/Help/html 141 | DocProject/Help/Html2 142 | ecf/ 143 | FakesAssemblies/ 144 | Generated_Code/ 145 | internal_test/ 146 | ipch/ 147 | nCrunchTemp_* 148 | node_modules/ 149 | orleans.codegen.cs 150 | Package.StoreAssociation.xml 151 | paket-files/ 152 | project.fragment.lock.json 153 | project.lock.json 154 | PublishScripts/ 155 | rcf/ 156 | TestResult.xml 157 | UpgradeLog*.htm 158 | UpgradeLog*.XML 159 | x64/ 160 | x86/ 161 | !src/**/[Bb]in/ 162 | -------------------------------------------------------------------------------- /src/usr/local/bin/__vpn_connect: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Connect to a Shadowsocks server. 4 | 5 | ################################################# 6 | # Configure internal implementation details. # 7 | ################################################# 8 | CONNECTION_PID="" 9 | 10 | ################################################# 11 | # Connects to a Shadowsocks server using 12 | # the specified access key. 13 | # Arguments: 14 | # $1. The access key to use. 15 | ################################################# 16 | connect() { 17 | echo "${0}: unsupported operation" >& 2 # CONNECT_TEMPLATE & 18 | CONNECTION_PID=$! 19 | [ -n "${CONNECTION_PID}" ] && wait "${CONNECTION_PID}" 20 | } 21 | 22 | ################################################# 23 | # Disconnects from the currently active server, 24 | # if any. Otherwise, does nothing. 25 | # Arguments: 26 | # None 27 | ################################################# 28 | disconnect() { 29 | [ -n "${CONNECTION_PID}" ] && kill -TERM "${CONNECTION_PID}" 30 | } 31 | 32 | ################################################# 33 | # Prints a brief help message. 34 | # Arguments: 35 | # None 36 | # Outputs: 37 | # Writes the help message to stdout. 38 | ################################################# 39 | help() { 40 | echo "Usage: ${0} [] []" 41 | echo 42 | echo "Connect to a Shadowsocks server." 43 | echo 44 | echo "Examples:" 45 | echo " ${0} -transport \"ss://...\"" 46 | echo 47 | echo "Options:" 48 | echo " -h, -help Display this help text and exit" 49 | echo " -transport The server access key (usually starts with \"ss://\")" 50 | } 51 | 52 | ################################################# 53 | # Formats and prints the provided error message, 54 | # displays the help page, and terminates the 55 | # process. 56 | # Arguments: 57 | # $1. The error message to format and print. 58 | # Outputs: 59 | # Writes the formatted error message to stderr. 60 | # Returns: 61 | # Never returns (exits with a status of 1). 62 | ################################################# 63 | fatal_error() { 64 | echo "${0}: ${1}" >& 2 65 | help >& 2 66 | exit 1 67 | } 68 | 69 | ################################################# 70 | # The main entry point for the script. 71 | # Arguments: 72 | # ... A list of the command line arguments. 73 | ################################################# 74 | main() { 75 | local transport="" 76 | 77 | # Parse the arguments and options. 78 | while [ -n "${1}" ]; do 79 | case "${1}" in 80 | -h|-help|--help) help; exit 0 ;; 81 | -t|-transport|--transport) transport="${2}"; shift ;; 82 | -*) fatal_error "invalid option: ${1}" ;; 83 | *) fatal_error "invalid argument: ${1}" ;; 84 | esac 85 | shift 2> /dev/null 86 | done 87 | 88 | connect "${transport}" 89 | } 90 | 91 | trap disconnect HUP INT QUIT TERM 92 | main "${@}" 93 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [kira.canary@proton.me](mailto:kira.canary@proton.me). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Outline CLI 2 | 3 | [![GitHub CI Status](https://img.shields.io/github/actions/workflow/status/Kira-NT/outline-cli/ci.yml?logo=github)](https://github.com/Kira-NT/outline-cli/actions/workflows/ci.yml) 4 | [![Version](https://img.shields.io/github/v/release/Kira-NT/outline-cli?sort=date&label=version)](https://github.com/Kira-NT/outline-cli/releases/latest) 5 | [![License](https://img.shields.io/github/license/Kira-NT/outline-cli?cacheSeconds=36000)](LICENSE.md) 6 | 7 | Outline CLI Icon 8 | 9 | [Outline](https://getoutline.org/), developed by [Jigsaw](https://jigsaw.google.com/), is a great project aimed at simplifying the task of creating self-hosted VPN servers down to a matter of seconds. However, while the server deployment with Outline is commendable, its client-side experience falls short. Currently, the primary method of connecting to an [Outline server](https://github.com/Jigsaw-Code/outline-server/) is via the [Outline Client](https://github.com/Jigsaw-Code/outline-apps/) - an Electron *(or Cordova for mobile devices)* app - which unfortunately presents lots of issues across different platforms. It is somewhat buggy on Windows, notably unreliable on macOS, and entirely non-functional on Linux *(at least if Linux means more than just Ubuntu to you)*. 10 | 11 | Fortunately, at least for Linux users, there's an alternative - a [CLI solution](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/) hidden within the "experimental" section of the [outline-sdk](https://github.com/Jigsaw-Code/outline-sdk/) repo, which only supports Linux at the moment of writing. Despite its minimalistic design, the CLI performs its sole function - establishing a connection to a selected server and rerouting all traffic through it - quite efficiently and reliably. However, it may not be very user-friendly to manually launch/restart it, manage your access keys, and forcibly stop it when you wish to disconnect from the VPN server, and so on. This is where this project comes in. 12 | 13 | This project, essentially a wrapper over the official [outline-cli](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/) provided by [Jigsaw](https://github.com/Jigsaw-Code/), aims to make its usage easier. Comprised of just a few shell scripts, it enhances the default experience into a near-perfect one - unachievable by a buggy Electron app ;) 14 | 15 | ---- 16 | 17 | ## Features 18 | 19 | Obviously, the official CLI provides you with the ability to connect to servers deployed by the Outline Manager, or to any Shadowsocks server, for that matter. Here is what this project offers on top of that: 20 | 21 | - Access key management 22 | - [`ssconf://`](https://www.reddit.com/r/outlinevpn/wiki/index/dynamic_access_keys/) support 23 | - Option to specify a custom DNS resolver instead of the hardcoded one 24 | - Ability to connect to devices on your local network while the VPN is active 25 | - Automatic reconnection to the VPN server upon reboot/network status change *(requires `NetworkManager`)* 26 | - Easy integration with other tools, thanks to the highly scriptable nature of this project 27 | 28 | ---- 29 | 30 | ## Installation 31 | 32 | The installation process is quite straightforward: just clone this repo and run the `install.sh` script. 33 | 34 | ```sh 35 | git clone https://github.com/Kira-NT/outline-cli 36 | cd outline-cli 37 | sudo ./install.sh -y 38 | ``` 39 | 40 | Alternatively, for `curl $URL | sudo bash` enjoyers, the same result can be achieved with the following one-liner: 41 | 42 | ```sh 43 | curl -Ls https://github.com/Kira-NT/outline-cli/blob/master/install.sh?raw=true | sudo bash -s -- -y 44 | ``` 45 | 46 | This will: 47 | 48 | 1) Install any missing dependencies: `git`, `tar`, `jq`, `curl` or `wget`, `gcc` or `clang` 49 | 2) Build and install the official [`outline-cli`](https://github.com/Jigsaw-Code/outline-sdk/tree/main/x/examples/outline-cli/) 50 | 1) Clone [`Jigsaw-Code/outline-sdk`](https://github.com/Jigsaw-Code/outline-sdk/) 51 | 2) Apply a patch that allows you to specify a custom DNS resolver instead of the hardcoded one 52 | 3) Download the latest version of Go *(locally, not globally)* 53 | 4) Build the app 54 | 5) Copy the resulting binary to `/usr/local/bin/__vpn_connect` 55 | 6) Clean up 56 | 3) Copy the `__vpn_manager` script to `/usr/local/bin/__vpn_manager` 57 | 4) Create a symlink for `__vpn_manager`, enabling you to call it using the shorthand `vpn` 58 | 5) Create a `polkit-1` policy, enabling you to call `__vpn_manager` from scripts 59 | 6) Copy the `vpn-manager-refresh` script to `/etc/NetworkManager/dispatcher.d/vpn-manager-refresh`, ensuring it executes each time your network connection status changes 60 | 61 | If you wish to configure the process described above, you can run the installation script without the `-y` flag, allowing you to guide it and adjust the results according to your preferences. 62 | 63 | ---- 64 | 65 | ## Usage 66 | 67 | ``` 68 | Usage: vpn [] [] 69 | 70 | Manage Shadowsocks server connections and related access keys. 71 | 72 | Examples: 73 | sudo vpn add "ss://..." "Geneva" 74 | sudo vpn connect geneva 75 | sudo vpn disconnect 76 | 77 | Commands: 78 | add [] Add a new access key 79 | remove Remove the designated access key 80 | list [-f ] List all stored access keys 81 | connect [] Connect to a server 82 | disconnect [-s] Disconnect from the current server 83 | toggle Toggle the current connection 84 | status Return the current connection status 85 | 86 | Options: 87 | -h, --help Display this help text and exit 88 | -v, --version Display version information and exit 89 | -q, --quiet Suppress all normal output 90 | -n, --notify Display a notification 91 | -s, --suspend Suspend the current connection; 92 | It will be re-established later 93 | -f, --format Print a key according to the ; 94 | The formats are: %name%, %ip%, %index% 95 | ``` 96 | 97 | First and foremost, akin to the GUI app, you must add an access key. For example: 98 | 99 | ```sh 100 | sudo vpn add "ss://YFhvLUKmUBJwEsSfL65ShwAOzuLDNN0HTBh9rCb2yhIJdrMXBhgP1DXBm4y7@42.42.42.42:52683/?outline=1" "Geneva" 101 | ``` 102 | 103 | You can list all stored access keys using the `sudo vpn list` command: 104 | 105 | ``` 106 | 1 Geneva 42.42.42.42:52683 107 | ``` 108 | 109 | Once done, you're all set! Use: 110 | 111 | ```sh 112 | sudo vpn connect "Geneva" 113 | ``` 114 | 115 | or one of these alternatives: 116 | 117 | ```sh 118 | # The access key name is case-insensitive. 119 | sudo vpn connect geneva 120 | 121 | # You can also use the saved access key's ID 122 | # (i.e., its ordinal number from the `list` output). 123 | sudo vpn connect 1 124 | 125 | # You can also omit the name/ID entirely. 126 | # This will connect you to the last server you connected to. 127 | sudo vpn connect 128 | ``` 129 | 130 | to connect to the desired VPN server. 131 | 132 | Whenever you wish to disconnect from the current server, simply use: 133 | 134 | ```sh 135 | sudo vpn disconnect 136 | ``` 137 | 138 | And that about covers the most essential functionalities you'll need. 139 | 140 | ---- 141 | 142 | ## Keyboard Shortcuts 143 | 144 | While this project doesn't provide a built-in solution for automatically creating keyboard shortcuts *(since everyone does it differently, and there's no point in even attempting to support every DE/WM/key remapping tool in existence)*, it is designed to be easily integrated with other tools and is highly scriptable. 145 | 146 | Here’s a table with some common actions you might want to perform and the commands that achieve the desired results: 147 | 148 | | Description | Command | 149 | |:--------------------------------------------------------|:----------------------------------------------------| 150 | | Toggle the current connection | `pkexec /usr/local/bin/__vpn_manager toggle -n` | 151 | | Disconnect from the current server | `pkexec /usr/local/bin/__vpn_manager disconnect -n` | 152 | | Connect to the server you were last connected to | `pkexec /usr/local/bin/__vpn_manager connect -n` | 153 | | Show the current connection status | `pkexec /usr/local/bin/__vpn_manager status -n` | 154 | | Connect to a server using the 1st access key | `pkexec /usr/local/bin/__vpn_manager connect 1 -n` | 155 | | Connect to a server using the 2nd access key | `pkexec /usr/local/bin/__vpn_manager connect 2 -n` | 156 | | Connect to a server using the 3rd access key | `pkexec /usr/local/bin/__vpn_manager connect 3 -n` | 157 | | Connect to a server using the 4th access key | `pkexec /usr/local/bin/__vpn_manager connect 4 -n` | 158 | | Connect to a server using the 5th access key | `pkexec /usr/local/bin/__vpn_manager connect 5 -n` | 159 | | Connect to a server using the 6th access key | `pkexec /usr/local/bin/__vpn_manager connect 6 -n` | 160 | | Connect to a server using the 7th access key | `pkexec /usr/local/bin/__vpn_manager connect 7 -n` | 161 | | Connect to a server using the 8th access key | `pkexec /usr/local/bin/__vpn_manager connect 8 -n` | 162 | | Connect to a server using the 9th access key | `pkexec /usr/local/bin/__vpn_manager connect 9 -n` | 163 | 164 | With this, you can easily configure keyboard shortcuts either directly via the keyboard settings provided by your DE/WM *(e.g., KDE, GNOME, etc.)*, or via a key remapping tool of your choice. 165 | 166 | For example, I use [`keyd`](https://github.com/rvaiya/keyd/) - a very nice, modular, and efficient key remapping daemon. You can check my config, which implements all the shortcuts mentioned above, here: [`src/etc/keyd/vpn-manager.conf`](src/etc/keyd/vpn-manager.conf). 167 | 168 | ---- 169 | 170 | ## Custom DNS Resolver 171 | 172 | The Outline Client hardcodes its preferred DNS resolver. This has already caused many problems for numerous users in the past and will undoubtedly cause even more issues in the future. 173 | 174 | While striving for the perfect solution is understandable, "perfect" can often be the enemy of "good." This is precisely what we see here, as after all these years, there is still no option to override these hardcoded values. Hence, this project proposes a simple eight-line patch that allows you to supply a DNS resolver of your choice to the Outline Client. 175 | 176 | If you followed the standard installation script, you can specify the DNS resolver you prefer directly in your access key via the new `dns` URL parameter, as demonstrated below: 177 | 178 | ```diff 179 | - ss://.../?outline=1 180 | + ss://.../?outline=1&dns=1.1.1.1 181 | ``` 182 | 183 | ---- 184 | 185 | ## Legal Notice 186 | 187 | This project is an independent work and is not affiliated, endorsed, authorized, or sponsored by Jigsaw LLC or any of its subsidiaries or affiliates. Outline and the Outline logo are trademarks and/or registered trademarks of Jigsaw LLC in the U.S. and/or other countries. All rights reserved to their respective owners. 188 | 189 | The purpose of this project is purely educational and non-commercial. The code and information found within this project are for educational use only and are not intended for any kind of commercial use. 190 | 191 | Use this software at your own risk. The creators cannot be held responsible for any misuse or damages caused by this software. 192 | 193 | This notice serves as a disclaimer. Any violations of Jigsaw's policies or trademarks are not intentional. 194 | 195 | ---- 196 | 197 | ## License 198 | 199 | Licensed under the terms of the [MIT License](LICENSE.md). 200 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Install the Outline Client CLI. 4 | 5 | ################################################# 6 | # The URL of the repo that contains this file. 7 | ################################################# 8 | REPO="https://github.com/Kira-NT/outline-cli" 9 | 10 | ################################################# 11 | # The default name for the __vpn_manager symlink. 12 | ################################################# 13 | DEFAULT_VPN_MANAGER_NAME="vpn" 14 | 15 | ################################################# 16 | # Indicates whether the user should be prompted 17 | # for input or if default response values should 18 | # be used instead. 19 | ################################################# 20 | USE_DEFAULT_RESPONSE=false 21 | 22 | ################################################# 23 | # Formats and prints the provided error message. 24 | # Arguments: 25 | # $1. The error message to format and print. 26 | # Outputs: 27 | # Writes the formatted error message to stderr. 28 | # Returns: 29 | # Always returns 1. 30 | ################################################# 31 | error() { 32 | echo "${0}: ${1}" >& 2 33 | return 1 34 | } 35 | 36 | ################################################# 37 | # Checks if the specified command exists. 38 | # Arguments: 39 | # $1. The command to check. 40 | # Returns: 41 | # 0 if the specified command exists; 42 | # otherwise, a non-zero status. 43 | ################################################# 44 | command_exists() { 45 | command -v "${1}" > /dev/null 2>& 1 46 | } 47 | 48 | ################################################# 49 | # Asks the user for confirmation. 50 | # Arguments: 51 | # $1. The message to display. 52 | # $2. The default response. 53 | # Inputs: 54 | # Reads user's input from stdin. 55 | # Outputs: 56 | # Writes the message to stderr and prompts 57 | # the user for yes/no confirmation. 58 | # Returns: 59 | # 0 if the user confirmed the action. 60 | # 1 if the user denied the action. 61 | ################################################# 62 | confirm() { 63 | local confirm_response="${2}" 64 | if [ -z "${confirm_response}" ] || [ "${USE_DEFAULT_RESPONSE}" != "true" ]; then 65 | echo -n "${1} [Y/n]: " >& 2 66 | read -r confirm_response 67 | fi 68 | 69 | case "$(echo "${confirm_response}" | tr '[:upper:]' '[:lower:]')" in 70 | y|yes|yep) return 0 ;; 71 | n|no|nope) return 1 ;; 72 | *) confirm "${1}" ;; 73 | esac 74 | } 75 | 76 | ################################################# 77 | # Prompts the user for input. 78 | # Arguments: 79 | # $1. The message to display. 80 | # $2. The default response. 81 | # Inputs: 82 | # Reads user's input from stdin. 83 | # Outputs: 84 | # Writes the message to stderr. 85 | # Writes the user's input to stdout. 86 | # Returns: 87 | # 0 if the response is not empty; otherwise, 1. 88 | ################################################# 89 | prompt() { 90 | local prompt_response="${2}" 91 | if [ -z "${prompt_response}" ] || [ "${USE_DEFAULT_RESPONSE}" != "true" ]; then 92 | echo -n "${1}: " >& 2 93 | read -r prompt_response 94 | fi 95 | 96 | echo "${prompt_response}" 97 | [ -n "${prompt_response}" ] 98 | } 99 | 100 | ################################################# 101 | # Installs the specified package(s) using the 102 | # appropriate package manager for the system. 103 | # Arguments: 104 | # ... One or more package names to install. 105 | # Returns: 106 | # 0 if the operation succeeds; 107 | # otherwise, a non-zero status if no supported 108 | # package manager is found or the installation 109 | # fails. 110 | ################################################# 111 | install_package() { 112 | if command_exists apt-get; then 113 | apt-get install -y "${@}" 114 | elif command_exists dnf; then 115 | dnf install -y "${@}" 116 | elif command_exists pacman; then 117 | pacman -S --noconfirm "${@}" 118 | else 119 | return 1 120 | fi 121 | } 122 | 123 | ################################################# 124 | # Gets the operating system name. 125 | # Arguments: 126 | # None 127 | # Outputs: 128 | # Writes the name of the OS to stdout. 129 | ################################################# 130 | os_name() { 131 | uname -s | tr '[:upper:]' '[:lower:]' 132 | } 133 | 134 | ################################################# 135 | # Determines the operating system type combining 136 | # OS name and architecture. 137 | # Arguments: 138 | # None 139 | # Outputs: 140 | # Writes the type of the OS to stdout, 141 | # formatted as <>-<>, e.g., "linux-amd64", 142 | # "darwin-arm64", etc. 143 | ################################################# 144 | os_type() { 145 | case "$(uname -m)" in 146 | x86_64) echo "$(os_name)-amd64" ;; 147 | aarch64|armv8) echo "$(os_name)-arm64" ;; 148 | armv6|armv7l) echo "$(os_name)-armv6l" ;; 149 | i686|.*386.*) echo "$(os_name)-386" ;; 150 | *) echo "$(os_name)-$(uname -m)" ;; 151 | esac 152 | } 153 | 154 | ################################################# 155 | # Downloads a file. 156 | # Arguments: 157 | # $1. The URL of the file to download. 158 | # $2. The destination of the downloaded file. 159 | # If not provided, the file will be written 160 | # to stdout. 161 | # Returns: 162 | # 0 if the operation succeeds; 163 | # otherwise, a non-zero status. 164 | ################################################# 165 | download() { 166 | if command_exists wget; then 167 | wget -O "${2:-"-"}" "${1}" 168 | elif command_exists curl; then 169 | curl -Lo "${2:-"-"}" "${1}" 170 | fi 171 | } 172 | 173 | ################################################# 174 | # Gets the latest version of Go. 175 | # Arguments: 176 | # None 177 | # Outputs: 178 | # Writes the version number of the latest 179 | # Go version (e.g., '1.20.0') to stdout. 180 | # Returns: 181 | # 0 if the operation succeeds; 182 | # otherwise, a non-zero status. 183 | ################################################# 184 | go_version_latest() { 185 | download "https://go.dev/dl/?mode=json" | jq -r '.[0].version' | grep -o '[0-9\.]*' 2> /dev/null 186 | } 187 | 188 | ################################################# 189 | # Downloads the specified version of Go. 190 | # Arguments: 191 | # $1. The destination of the downloaded file. 192 | # If not provided, defaults to "go.tar.gz". 193 | # $2. The version of Go to download. 194 | # If not provided, downloads the latest 195 | # version. 196 | # Returns: 197 | # 0 if the operation succeeds; 198 | # otherwise, a non-zero status. 199 | ################################################# 200 | go_download() { 201 | download "https://go.dev/dl/go${2:-"$(go_version_latest)"}.$(os_type).tar.gz" "${1:-"go.tar.gz"}" >& 2 202 | } 203 | 204 | ################################################# 205 | # Installs the specified version of Go. 206 | # Arguments: 207 | # $1. The Go installation directory. 208 | # If not provided, defaults to ".go". 209 | # $2. The version of Go to install. 210 | # If not provided, installs the latest 211 | # version. 212 | # Outputs: 213 | # Writes the full path of the Go binary 214 | # to stdout. 215 | # Returns: 216 | # 0 if the operation succeeds; 217 | # otherwise, a non-zero status. 218 | ################################################# 219 | go_install() { 220 | local go_cwd="${PWD}" 221 | local go_path="${1:-".go"}" 222 | local go_tmp="go@${2:-"latest"}.tmp${$}.tar.gz" 223 | 224 | if [ -d "${go_path}" ] && [ -n "$(ls -A "${go_path}")" ]; then 225 | error "failed to install Go: Already installed" 226 | return 1 227 | fi 228 | 229 | if ! go_download "${go_tmp}" "${2}"; then 230 | error "failed to install Go: Could not download the binary" 231 | return 1 232 | fi 233 | 234 | mkdir -p "${go_path}" 235 | tar xf "${go_tmp}" --directory="${go_path}" --strip=1 236 | if [ $? -ne 0 ] || [ ! -x "${go_path}/bin/go" ]; then 237 | error "failed to install Go: Could not extract the binary" 238 | rm "${go_tmp}" 239 | rm -rf "${go_path}" 240 | return 1 241 | fi 242 | 243 | rm "${go_tmp}" 244 | 245 | cd "${go_path}" 246 | echo "${PWD}/bin/go" 247 | cd "${go_cwd}" 248 | } 249 | 250 | ################################################# 251 | # Clones the 'outline-sdk' repo, applies patches 252 | # to it (if provided), builds the 'outline-cli' 253 | # Go binary, then cleans up the cloned repo. 254 | # Arguments: 255 | # $1. The output file name for the binary. 256 | # Defaults to "outline". 257 | # $2. The location of the Go binary to use. 258 | # Defaults to "go". 259 | # ... The patches to be applied to the repo. 260 | # Returns: 261 | # 0 if the operation succeeds; 262 | # otherwise, a non-zero status. 263 | ################################################# 264 | outline_install() { 265 | local go_build_output="${1:-"outline"}" 266 | local go_binary="${2:-"go"}" 267 | shift 2 268 | 269 | git clone "https://github.com/Jigsaw-Code/outline-sdk" && \ 270 | cd "./outline-sdk/x/examples/" 271 | if [ $? -ne 0 ]; then 272 | error "failed to build outline-cli: 'Jigsaw-Code/outline-sdk' is unreachable" 273 | return 1 274 | fi 275 | 276 | while [ -n "${1}" ]; do 277 | echo "Applying a patch: '${1}'..." >& 2 278 | git apply "../../../${1}" 279 | shift 280 | done 281 | 282 | local go_cache="${PWD}/.cache/go" 283 | local go_build_status=0 284 | echo "Building 'outline-cli' as '${go_build_output}'..." >& 2 285 | GOPATH="${go_cache}" GOCACHE="${go_cache}" "${go_binary}" build -o "outline-cli" "./outline-cli" >& 2 286 | go_build_status=$? 287 | cd "../../../" 288 | 289 | if [ "${go_build_status}" -eq 0 ]; then 290 | cp "./outline-sdk/x/examples/outline-cli/outline-cli" "${go_build_output}" 291 | go_build_status=$? 292 | fi 293 | 294 | rm -rf "./outline-sdk" 295 | 296 | if [ $? -ne 0 ]; then 297 | error "failed to build outline-cli: Compilation failed" 298 | fi 299 | return "${go_build_status}" 300 | } 301 | 302 | ################################################# 303 | # Modifies a given template file to replace 304 | # the placeholder connect function with an actual 305 | # command used to establish a VPN connection. 306 | # The placeholder is denoted by a line containing 307 | # "# CONNECT_TEMPLATE". 308 | # Arguments: 309 | # $1. The path to the template file. 310 | # $2. The actual command used to establish 311 | # a connection, for example, 312 | # `__outline -transport "${1}"`. 313 | # Returns: 314 | # 0 if the operation succeeds; 315 | # otherwise, a non-zero status. 316 | ################################################# 317 | outline_wrapper_install() { 318 | local template_line_number="$(sed -n '/# CONNECT_TEMPLATE/=' "${1}" 2> /dev/null)" 319 | 320 | [ -n "${template_line_number}" ] && \ 321 | sed -i"" "${template_line_number} c\ ${2:-"__outline -transport \"\${1}\""} &" "${1}" 322 | } 323 | 324 | ################################################# 325 | # Copies the source file to the target location 326 | # and sets the permissions on the copied file. 327 | # Arguments: 328 | # $1. The source file path. 329 | # $2. The target file path. 330 | # $3. The permissions to apply on 331 | # the copied file (in octal notation). 332 | # Returns: 333 | # 0 if the cp and chmod operations succeed; 334 | # otherwise, a non-zero status. 335 | ################################################# 336 | cpmod() { 337 | mkdir -m=${3} -p "$(dirname "${2}")" 338 | cp "${1}" "${2}" && chmod "${3}" "${2}" 339 | } 340 | 341 | ################################################# 342 | # Copies a file from the project's source 343 | # directory to the corresponding location 344 | # in the target system. 345 | # The function retrieves the destination from 346 | # the file's path by removing the initial 347 | # directory part. 348 | # Arguments: 349 | # $1. The source file path. 350 | # $2. The permissions to apply on 351 | # the copied file (in octal notation). 352 | # If not provided, defaults to 600. 353 | # Returns: 354 | # 0 if the operation succeeds; 355 | # otherwise, a non-zero status. 356 | ################################################# 357 | unwrap() { 358 | cpmod "${1}" "$(echo "${1}" | sed 's/^[^/]*//')" "${2:-600}" 359 | } 360 | 361 | ################################################# 362 | # Asserts that the current user is "root" (i.e., 363 | # a superuser). Otherwise, terminates the current 364 | # process. 365 | # Arguments: 366 | # None 367 | # Outputs: 368 | # Writes the error message, if any, to stderr. 369 | # Returns: 370 | # 0 if the current user is a superuser; 371 | # otherwise, never returns (exits the shell 372 | # with a status of 1). 373 | ################################################# 374 | assert_is_root() { 375 | [ "${EUID:-"$(id -u)"}" -eq 0 ] && return 376 | 377 | error "cannot perform the installation: Permission denied" 378 | exit 1 379 | } 380 | 381 | ################################################# 382 | # Asserts that the current working directory 383 | # contains the script being executed. 384 | # Arguments: 385 | # None 386 | # Outputs: 387 | # Writes the error message, if any, to stderr. 388 | # Returns: 389 | # 0 if the current working directory is valid; 390 | # otherwise, never returns (exits the shell 391 | # with a status of 1). 392 | ################################################# 393 | assert_valid_cwd() { 394 | [ -f "./${0##*/}" ] && return 395 | 396 | error "cannot perform the installation: Invalid working directory" 397 | exit 1 398 | } 399 | 400 | ################################################# 401 | # Asserts that the provided command or one of its 402 | # substitutions is available on the current 403 | # system. 404 | # Arguments: 405 | # $1. The command name to check. 406 | # ... The alternatives to check. 407 | # Outputs: 408 | # Writes the error message, if any, to stderr. 409 | # Returns: 410 | # 0 if the provided command is available; 411 | # otherwise, never returns (exits the shell 412 | # with a status of 1). 413 | ################################################# 414 | assert_installed() { 415 | local cmd_name="" 416 | for cmd_name in "${@}"; do 417 | command_exists "${cmd_name}" && return 418 | done 419 | 420 | if confirm "Do you want to install the missing dependency '${1}'?" "y"; then 421 | install_package "${1}" && return 422 | fi 423 | 424 | error "cannot perform the installation: ${1} is not installed" 425 | exit 1 426 | } 427 | 428 | ################################################# 429 | # Prints a brief help message. 430 | # Arguments: 431 | # None 432 | # Outputs: 433 | # Writes the help message to stdout. 434 | ################################################# 435 | help() { 436 | echo "Usage: ${0} []" 437 | echo 438 | echo "Install the Outline Client CLI." 439 | echo 440 | echo "Examples:" 441 | echo " sudo ${0} --yes" 442 | echo " sudo ${0} --undo" 443 | echo 444 | echo "Options:" 445 | echo " -h, --help Display this help text and exit" 446 | echo " -y, --yes Run the script without manual intervention" 447 | echo " -u, --undo Undo the changes made by this script" 448 | } 449 | 450 | ################################################# 451 | # Formats and prints the provided error message, 452 | # displays the help page, and terminates the 453 | # process. 454 | # Arguments: 455 | # $1. The error message to format and print. 456 | # Outputs: 457 | # Writes the formatted error message to stderr. 458 | # Returns: 459 | # Never returns (exits with a status of 1). 460 | ################################################# 461 | fatal_error() { 462 | error "${1}" 463 | help >& 2 464 | exit 1 465 | } 466 | 467 | ################################################# 468 | # Uninstalls outline-cli by removing all 469 | # the files and directories it may have created, 470 | # and undoing the network routing rule changes. 471 | # Arguments: 472 | # $1=false. Indicates whether to also remove 473 | # local user data. 474 | ################################################# 475 | uninstall() { 476 | assert_is_root 477 | 478 | # Previously, executables were located in `sbin` instead of `bin`. 479 | rm -f "/usr/local/sbin/__vpn_connect" 480 | rm -f "/usr/local/sbin/__vpn_manager" 481 | find "/usr/local/sbin/" -lname "/usr/local/sbin/__vpn_manager" -delete 482 | 483 | rm -f "/usr/local/bin/__vpn_connect" 484 | rm -f "/usr/local/bin/__vpn_manager" 485 | find "/usr/local/bin/" -lname "/usr/local/bin/__vpn_manager" -delete 486 | 487 | rm -f "/usr/share/polkit-1/actions/vpn-manager.policy" 488 | rm -f "/etc/NetworkManager/dispatcher.d/vpn-manager-refresh" 489 | 490 | if [ "${1}" = "true" ]; then 491 | rm -rf "/var/lib/outline" 492 | rm -f "/var/log/outline" 493 | fi 494 | } 495 | 496 | ################################################# 497 | # Cleans up the working directory by removing 498 | # temporary files and directories that might have 499 | # been created during the runtime of 500 | # the installation script. 501 | # Arguments: 502 | # None 503 | ################################################# 504 | cleanup() { 505 | git checkout -- "src/usr/local/bin/__vpn_connect" 506 | rm -rf .go 507 | } 508 | 509 | ################################################# 510 | # Installs the current version of Outline CLI. 511 | # Arguments: 512 | # None 513 | # Returns: 514 | # 0 if the operation succeeds; 515 | # otherwise, a non-zero status. 516 | ################################################# 517 | install_local() { 518 | # Ensure that prerequisites for the script are met. 519 | assert_is_root 520 | assert_valid_cwd 521 | assert_installed jq 522 | assert_installed git 523 | assert_installed tar 524 | assert_installed curl wget 525 | assert_installed gcc clang # Go needs a C/C++ compiler. 526 | 527 | # Clean up stuff from the previous installations. 528 | uninstall > /dev/null 2>& 1 529 | 530 | # Automatic cleanup on the process termination. 531 | trap cleanup EXIT 532 | 533 | # Prepare __vpn_connect. 534 | # It should either be replaced with outline-cli, or 535 | # modified to contain the actual logic necessary 536 | # for connecting to a VPN server. 537 | if confirm "Install 'outline-cli' by Jigsaw LLC?" "y"; then 538 | local outline_go_binary="go" 539 | local outline_go_version="$(prompt "Select a Go version to compile 'outline-cli' with [latest/local/1.22.0/...]" "latest")" 540 | if [ "${outline_go_version}" = "latest" ]; then 541 | outline_go_version="" 542 | fi 543 | if [ "${outline_go_version}" != "local" ]; then 544 | outline_go_binary="$(go_install ".go" "${outline_go_version}")" 545 | [ -n "${outline_go_binary}" ] || return 546 | fi 547 | 548 | if confirm "Apply patches from the 'patches/' directory?" "y"; then 549 | outline_install "src/usr/local/bin/__vpn_connect" "${outline_go_binary}" patches/* || return 550 | else 551 | outline_install "src/usr/local/bin/__vpn_connect" "${outline_go_binary}" || return 552 | fi 553 | else 554 | local outline_wrapper_cmd="$(prompt "Enter a command used to connect to a VPN server")" 555 | if [ -z "${outline_wrapper_cmd}" ]; then 556 | error "failed to create a wrapper: Invalid command" 557 | return 558 | fi 559 | 560 | outline_wrapper_install "src/usr/local/bin/__vpn_connect" "${outline_wrapper_cmd}" || return 561 | fi 562 | 563 | # Prompt for a name to symlink the __vpn_manager command. 564 | local vpn_manager_symlink_name="$(prompt "Enter a short name for the vpn-manager command [${DEFAULT_VPN_MANAGER_NAME}]" "${DEFAULT_VPN_MANAGER_NAME}")" 565 | 566 | # Unwrap the __vpn_connect and __vpn_manager commands, and 567 | # create a symlink for __vpn_manager with the user-specified name. 568 | unwrap "src/usr/local/bin/__vpn_connect" 500 && \ 569 | unwrap "src/usr/local/bin/__vpn_manager" 500 && \ 570 | ln -s "/usr/local/bin/__vpn_manager" "/usr/local/bin/${vpn_manager_symlink_name:-"${DEFAULT_VPN_MANAGER_NAME}"}" 571 | if [ $? -ne 0 ]; then 572 | uninstall 573 | return 574 | fi 575 | 576 | # Allow calls to __vpn_manager via pkexec. 577 | if command_exists pkexec; then 578 | unwrap "src/usr/share/polkit-1/actions/vpn-manager.policy" 644 579 | fi 580 | 581 | # NetworkManager integration. 582 | if command_exists NetworkManager && confirm "Enable NetworkManager integration?" "y"; then 583 | unwrap "src/etc/NetworkManager/dispatcher.d/vpn-manager-refresh" 500 584 | fi 585 | } 586 | 587 | ################################################# 588 | # Installs the latest version of Outline CLI. 589 | # Arguments: 590 | # None 591 | # Returns: 592 | # 0 if the operation succeeds; 593 | # otherwise, a non-zero status. 594 | ################################################# 595 | install_remote() { 596 | # Ensure that prerequisites for the script are met. 597 | assert_is_root 598 | assert_installed git 599 | 600 | # Clone the latest available tag and cd into it. 601 | git clone "${REPO}" --depth 1 --branch \ 602 | "$(git ls-remote --tags --sort="-v:refname" "${REPO}" | head -n 1 | cut -d/ -f3)" && \ 603 | [ -n "${REPO##*/}" ] && \ 604 | [ -f "./${REPO##*/}/install.sh" ] && \ 605 | cd "./${REPO##*/}" || \ 606 | return 607 | 608 | # Rebuild the arguments. 609 | local inline_args="" 610 | if [ "${USE_DEFAULT_RESPONSE}" = "true" ]; then 611 | inline_args="${inline_args} -y" 612 | fi 613 | 614 | # Perform the installation. 615 | ./install.sh ${inline_args} 616 | 617 | # Delete the cloned repo. 618 | rm -rf -- "../${REPO##*/}" 619 | } 620 | 621 | ################################################# 622 | # The main entry point for the script. 623 | # Arguments: 624 | # ... A list of the command line arguments. 625 | # Returns: 626 | # 0 if the operation succeeds; 627 | # otherwise, a non-zero status. 628 | ################################################# 629 | main() { 630 | # Parse the arguments and options. 631 | while [ -n "${1}" ]; do 632 | case "${1}" in 633 | -h|--help) help; exit 0 ;; 634 | -y|--yes) USE_DEFAULT_RESPONSE=true ;; 635 | -u|--undo|--uninstall) uninstall true; exit 0 ;; 636 | -*) fatal_error "invalid option: ${1}" ;; 637 | *) fatal_error "invalid argument: ${1}" ;; 638 | esac 639 | shift 2> /dev/null 640 | done 641 | 642 | if [ "${0##*/}" = "install.sh" ]; then 643 | # The script is being executed from a local file, 644 | # i.e., the repository has already been cloned. 645 | # Proceed to the main entry point. 646 | install_local 647 | else 648 | # The script has been piped into whatever is executing it now. 649 | # Proceed to the stub that will clone the repository and 650 | # run the script properly. 651 | install_remote <& 1 652 | fi 653 | } 654 | 655 | main "${@}" 656 | -------------------------------------------------------------------------------- /src/usr/local/bin/__vpn_manager: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Manage Shadowsocks server connections and related access keys. 4 | 5 | ################################################# 6 | # Configure app details. # 7 | ################################################# 8 | APP_NAME="outline" 9 | APP_VERSION="3.1.0" 10 | APP_DIRECTORY="/var/lib/${APP_NAME}" 11 | 12 | ################################################# 13 | # Configure file paths. # 14 | ################################################# 15 | CONFIG_FILENAME="${APP_DIRECTORY}/config.json" 16 | STATE_FILENAME="${APP_DIRECTORY}/state.json" 17 | LOG_FILENAME="${APP_DIRECTORY}/log.txt" 18 | 19 | ################################################# 20 | # Configure notifications. # 21 | ################################################# 22 | NOTIFICATION_TITLE="" 23 | NOTIFICATION_TIMEOUT="" 24 | NOTIFICATION_BODY_CONNECTED="" 25 | NOTIFICATION_BODY_DISCONNECTED="" 26 | NOTIFICATION_BODY_DISCONNECTED_UNKNOWN="" 27 | NOTIFICATION_ICON_SUCCESS="" 28 | NOTIFICATION_ICON_FAILURE="" 29 | NOTIFICATION_ICON_CONNECTED="" 30 | NOTIFICATION_ICON_DISCONNECTED="" 31 | 32 | ################################################# 33 | # Ensures that the config file exists. 34 | # Arguments: 35 | # None 36 | ################################################# 37 | init_config() { 38 | [ -f "${CONFIG_FILENAME}" ] && return 39 | 40 | mkdir -m 700 -p "$(dirname "${CONFIG_FILENAME}")" && 41 | touch "${CONFIG_FILENAME}" && 42 | chmod 600 "${CONFIG_FILENAME}" && 43 | cat "${APP_DIRECTORY}/storage" 2> /dev/null | jq -Rs '{ 44 | keys: . 45 | | split("\n") | map(. 46 | | split("=") | select(length > 1) 47 | | [(.[0] | gsub("%20";"=";"g")), (.[1:] | join("="))]) 48 | | to_entries | map({ id: (.key + 1), name: .value[0], url: .value[1] }), 49 | remotes: [], 50 | notifications: { 51 | title: "Outline", 52 | timeout: 5000, 53 | messages: { 54 | connected: "Connected to %name% (%ip%)", 55 | disconnected: "Disconnected from %name% (%ip%)", 56 | disconnected_unknown: "Disconnected" 57 | }, 58 | icons: { 59 | success: "dialog-positive", 60 | failure: "dialog-error", 61 | connected: "network-wireless", 62 | disconnected: "network-offline" 63 | } 64 | }, 65 | exclude: [ 66 | "192.168.0.0/16", 67 | "172.16.0.0/12", 68 | "10.0.0.0/8", 69 | "!10.233.233.0/24", 70 | ":22" 71 | ] 72 | }' > "${CONFIG_FILENAME}" && 73 | rm -f "${APP_DIRECTORY}/storage" 74 | } 75 | 76 | ################################################# 77 | # Ensures that the state file exists. 78 | # Arguments: 79 | # None 80 | ################################################# 81 | init_state() { 82 | [ -f "${STATE_FILENAME}" ] && return 83 | 84 | mkdir -m 700 -p "$(dirname "${STATE_FILENAME}")" && 85 | touch "${STATE_FILENAME}" && 86 | chmod 600 "${STATE_FILENAME}" && 87 | jq -n '{ status: "disconnected", key_id: null }' > "${STATE_FILENAME}" && 88 | rm -f "${APP_DIRECTORY}/session" 89 | } 90 | 91 | ################################################# 92 | # Ensures that the log file exists. 93 | # Arguments: 94 | # None 95 | ################################################# 96 | init_log() { 97 | [ -f "${LOG_FILENAME}" ] && return 98 | 99 | mkdir -m 700 -p "$(dirname "${LOG_FILENAME}")" && 100 | touch "${LOG_FILENAME}" && 101 | chmod 640 "${LOG_FILENAME}" && 102 | rm -f "/var/log/${APP_NAME}" 103 | } 104 | 105 | ################################################# 106 | # Initializes notification settings. 107 | # Arguments: 108 | # None 109 | ################################################# 110 | init_notifications() { 111 | eval "$(jq -r ' 112 | (.notifications // {}) as $n | 113 | ($n.messages // {}) as $m | 114 | ($n.icons // {}) as $i | 115 | "NOTIFICATION_TITLE=\(($n.title // "Outline")|@sh)", 116 | "NOTIFICATION_TIMEOUT=\(($n.timeout // 5000)|@sh)", 117 | "NOTIFICATION_BODY_CONNECTED=\(($m.connected // "Connected to %name% (%ip%)")|@sh)", 118 | "NOTIFICATION_BODY_DISCONNECTED=\(($m.disconnected // "Disconnected from %name% (%ip%)")|@sh)", 119 | "NOTIFICATION_BODY_DISCONNECTED_UNKNOWN=\(($m.disconnected_unknown // "Disconnected")|@sh)", 120 | "NOTIFICATION_ICON_SUCCESS=\(($i.success // "dialog-positive")|@sh)", 121 | "NOTIFICATION_ICON_FAILURE=\(($i.failure // "dialog-error")|@sh)", 122 | "NOTIFICATION_ICON_CONNECTED=\(($i.connected // "network-wireless")|@sh)", 123 | "NOTIFICATION_ICON_DISCONNECTED=\(($i.disconnected // "network-offline")|@sh)" 124 | ' "${CONFIG_FILENAME}")" 125 | } 126 | 127 | ################################################# 128 | # Formats and prints the provided error message. 129 | # Arguments: 130 | # $1. The error message to format and print. 131 | # Outputs: 132 | # Writes the formatted error message to stderr. 133 | # Returns: 134 | # Always returns 1. 135 | ################################################# 136 | error() { 137 | echo "${0}: ${1}" >& 2 138 | return 1 139 | } 140 | 141 | ################################################# 142 | # Checks if the specified command exists. 143 | # Arguments: 144 | # $1. The command to check. 145 | # Returns: 146 | # 0 if the specified command exists; 147 | # otherwise, a non-zero status. 148 | ################################################# 149 | command_exists() { 150 | command -v "${1}" > /dev/null 2>& 1 151 | } 152 | 153 | ################################################# 154 | # Overwrites a specified file with the contents 155 | # from stdin, maintaining the file permissions. 156 | # Arguments: 157 | # $1. The filename to overwrite. 158 | # Inputs: 159 | # Reads new content from stdin. 160 | # Outputs: 161 | # Writes the new content to the specified file. 162 | ################################################# 163 | overwrite() { 164 | awk 'BEGIN{RS="";getline<"-";print>ARGV[1]}' "${1}" 165 | } 166 | 167 | ################################################# 168 | # Downloads a file. 169 | # Arguments: 170 | # $1. The URL of the file to download. 171 | # $2. The destination of the downloaded file. 172 | # If not provided, the file will be written 173 | # to stdout. 174 | # Returns: 175 | # 0 if the operation succeeds; 176 | # otherwise, a non-zero status. 177 | ################################################# 178 | download() { 179 | if command_exists wget; then 180 | wget -O "${2:-"-"}" "${1}" 181 | elif command_exists curl; then 182 | curl -Lo "${2:-"-"}" "${1}" 183 | fi 184 | } 185 | 186 | ################################################# 187 | # Decodes a percent-encoded URI component. 188 | # Arguments: 189 | # None 190 | # Inputs: 191 | # Reads the encoded URI component from stdin. 192 | # Outputs: 193 | # Writes the decoded string to stdout. 194 | ################################################# 195 | urid() { 196 | printf "%b" "$(sed -E 's/%([a-fA-F0-9]{2})/\\\x\1/g')" 197 | } 198 | 199 | ################################################# 200 | # Sends a desktop notification. 201 | # Arguments: 202 | # $1. The title of the notification. 203 | # $2="". The body of the notification. 204 | # $3="". The icon of the notification. 205 | # $4=3000. The duration of the notification. 206 | # Returns: 207 | # 0 if the notification has been sent; 208 | # otherwise, a non-zero status. 209 | ################################################# 210 | send_notification() { 211 | local display=":0" 212 | local display_user="$(users | sed 's/\s.*//g')" 213 | local display_user_id="$(id -u "${display_user}" 2> /dev/null)" 214 | 215 | if command_exists notify-send; then 216 | sudo -u "${display_user}" \ 217 | DISPLAY="${display}" \ 218 | DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${display_user_id}/bus \ 219 | notify-send -a "${1}" -i "${3}" -t "${4:-3000}" "${1}" "${2}" 220 | elif command_exists kdialog; then 221 | sudo -u "${display_user}" \ 222 | DISPLAY="${display}" \ 223 | DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${display_user_id}/bus \ 224 | kdialog --passivepopup "${2}" "$((${4:-3000} / 1000))" --title "${1}" --icon "${3}" 225 | else 226 | return 1 227 | fi > /dev/null 2>& 1 228 | } 229 | 230 | ################################################# 231 | # Asserts that the current user is "root" (i.e., 232 | # a superuser). Otherwise, terminates the current 233 | # process. 234 | # Arguments: 235 | # None 236 | # Outputs: 237 | # Writes the error message, if any, to stderr. 238 | # Returns: 239 | # 0 if the current user is a superuser; 240 | # otherwise, never returns (exits the shell 241 | # with a status of 1). 242 | ################################################# 243 | assert_is_root() { 244 | [ "${EUID:-"$(id -u)"}" -eq 0 ] && return 245 | 246 | error "cannot query current connection status: Permission denied" 247 | exit 1 248 | } 249 | 250 | ################################################# 251 | # Checks if the given string is a valid 252 | # Shadowsocks URI. 253 | # Arguments: 254 | # $1. The string to check. 255 | # Returns: 256 | # 0 if the string is a valid Shadowsocks URI; 257 | # otherwise, a non-zero status. 258 | ################################################# 259 | is_ss() { 260 | echo "${1}" | grep -qE '^ss://[a-zA-Z0-9+/]+={0,3}@.+:[0-9]{1,5}(/.*)?(#.*)?$' 261 | } 262 | 263 | ################################################# 264 | # Checks if the given string is a valid 265 | # base64-encoded Shadowsocks URI. 266 | # Arguments: 267 | # $1. The string to check. 268 | # Returns: 269 | # 0 if the given string is a valid 270 | # base64-encoded Shadowsocks URI; 271 | # otherwise, a non-zero status. 272 | ################################################# 273 | is_ss64() { 274 | echo "${1}" | grep -qE '^ss://[a-zA-Z0-9+/]+={0,3}(/.*)?(#.*)?$' 275 | } 276 | 277 | ################################################# 278 | # Checks if the given string is a valid 279 | # Shadowsocks configuration URI. 280 | # Arguments: 281 | # $1. The string to check. 282 | # Returns: 283 | # 0 if the given string is a valid 284 | # Shadowsocks configuration URI; 285 | # otherwise, a non-zero status. 286 | ################################################# 287 | is_ssconf() { 288 | echo "${1}" | grep -qE '^ssconf://.+' 289 | } 290 | 291 | ################################################# 292 | # Checks if the given string is a valid JSON 293 | # object. 294 | # Arguments: 295 | # $1. The string to check. 296 | # Returns: 297 | # 0 if the given string is a valid JSON object; 298 | # otherwise, a non-zero status. 299 | ################################################# 300 | is_json() { 301 | echo "${1}" | grep -qE '^\s*{' 302 | } 303 | 304 | ################################################# 305 | # Checks if the given string is a valid transport 306 | # (i.e., the server access key). 307 | # Arguments: 308 | # $1. The transport string to check. 309 | # Returns: 310 | # 0 if the given string is a valid transport; 311 | # otherwise, a non-zero status. 312 | ################################################# 313 | is_valid_transport() { 314 | is_ss "${1}" || is_ss64 "${1}" || is_ssconf "${1}" 315 | } 316 | 317 | ################################################# 318 | # Normalizes the given access key by converting 319 | # it to its actual static representation. 320 | # Arguments: 321 | # $1. The key to normalize. 322 | # Outputs: 323 | # Writes the normalized key to stdout. 324 | ################################################# 325 | normalize_key() { 326 | if is_ss "${1}"; then 327 | echo "${1}" 328 | elif is_ss64 "${1}"; then 329 | ss642ss "${1}" 330 | elif is_ssconf "${1}"; then 331 | ssconf2ss "${1}" 332 | elif is_json "${1}"; then 333 | json2ss "${1}" 334 | else 335 | echo "${1}" 336 | fi | sed 's/[=]*@/@/' 337 | } 338 | 339 | ################################################# 340 | # Converts an encoded Shadowsocks URI to its 341 | # standard Shadowsocks URI format. 342 | # Arguments: 343 | # $1. The encoded Shadowsocks URI. 344 | # Outputs: 345 | # Writes the Shadowsocks URI to stdout. 346 | ################################################# 347 | ss642ss() { 348 | jq -nr --arg key "${1}" '$key 349 | | capture("^ss://(?[a-zA-Z0-9+/]+?={0,3})(?(/|\\?|#)[^/]*)?$") 350 | | [ 351 | (.val | @base64d | gsub("\\s"; "") | split(":|@"; null) | .[]), 352 | (.rem // "") 353 | ] 354 | | "ss://\("\(.[0]):\(.[1])"|@base64|gsub("=";""))@\(.[2]):\(.[3])\(.[4])"' 355 | } 356 | 357 | ################################################# 358 | # Converts a dynamic access key to its actual 359 | # static representation. 360 | # Arguments: 361 | # $1. The dynamic key to convert. 362 | # Outputs: 363 | # Writes the normalized static key to stdout. 364 | ################################################# 365 | ssconf2ss() { 366 | normalize_key "$(download "$(echo "${1}" | sed 's|ssconf://|https://|')" 2> /dev/null)" 367 | } 368 | 369 | ################################################# 370 | # Converts a JSON representation of a Shadowsocks 371 | # configuration to its URI format. 372 | # Arguments: 373 | # $1. The JSON string representing 374 | # the Shadowsocks configuration. 375 | # Outputs: 376 | # Writes the Shadowsocks URI to stdout. 377 | ################################################# 378 | json2ss() { 379 | jq -nr --argjson key "${1}" '$key | 380 | "ss://\( 381 | "\(.method):\(.password)" | @base64 | gsub("="; "") 382 | )@\(.server):\(.server_port)\( 383 | (.prefix | select(length > 0) | "/?prefix=\(@uri)") // "" 384 | )\( 385 | (.name | select(length > 0) | "#\(@uri)") // "" 386 | )"' 387 | } 388 | 389 | ################################################# 390 | # Formats and prints all access key entries based 391 | # on the provided format string. 392 | # The formats available for substitution are: 393 | # %name% - replaced with the access key name 394 | # %ip% - replaced with the access key IP 395 | # %index% - replaced with the access key ID 396 | # Arguments: 397 | # $1. A composite format string. 398 | # $2. A JSON file containing access keys. 399 | # Inputs: 400 | # If $2 is omitted, reads the access key 401 | # entries to format from stdin. 402 | ################################################# 403 | format_keys() { 404 | jq -r --arg format "${1}" ' 405 | (if (.id|type) == "number" then . else .keys[]? end) as $key | 406 | $format 407 | | split("%index%") | join($key.id|tostring) 408 | | split("%name%") | join($key.name) 409 | | split("%ip%") | join(($key.access_url // $key.url) 410 | | capture(".*@(?.+:[0-9]{1,5})(/|#|$).*").ip? // "N/A")' "${2:-"-"}" 411 | } 412 | 413 | ################################################# 414 | # Prints the given access key entry according to 415 | # the provided format string. 416 | # Arguments: 417 | # $1. A composite format string. 418 | # $2. The access key entry to format. 419 | # Outputs: 420 | # Writes the formatted entry to stdout. 421 | ################################################# 422 | format_key() { 423 | echo "${2}" | format_keys "${1}" 424 | } 425 | 426 | ################################################# 427 | # Infers the name of the given key. 428 | # Arguments: 429 | # $1. The key to infer the name for. 430 | # Outputs: 431 | # Writes the inferred name to stdout. 432 | ################################################# 433 | infer_key_name() { 434 | if echo "${1}" | grep -qF '#'; then 435 | echo "${1}" | sed 's/[^#]*#//' | urid 436 | else 437 | echo "${1}" | grep -oE '@.+:[0-9]{1,5}(/|#|$)' | grep -oE '[^@]+:[0-9]+' 438 | fi 439 | } 440 | 441 | ################################################# 442 | # Retrieves an access key entry by name or index. 443 | # Arguments: 444 | # $1. The name/index of the access key to get. 445 | # Outputs: 446 | # Writes the found key, if any, to stdout. 447 | # Returns: 448 | # 0 if the access key was found; 449 | # otherwise, a non-zero status. 450 | ################################################# 451 | get_key_entry() { 452 | jq -ce --arg query "${1}" ' 453 | (try ($query|tonumber) catch ($query|ascii_downcase)) as $q | 454 | first(.keys[]? | select((.id == $q) or ((.name|ascii_downcase) == $q))) 455 | ' "${CONFIG_FILENAME}" 456 | } 457 | 458 | ################################################# 459 | # Adds a new access key to the storage. 460 | # Arguments: 461 | # $1. The access key to store. 462 | # $2. The name of the access key. 463 | # $3. The access url associated with the key. 464 | # Returns: 465 | # 0 if the access key was added; 466 | # otherwise, a non-zero status. 467 | ################################################# 468 | add_key() { 469 | is_valid_transport "${1}" || return 470 | local access_url="${3:-"$(normalize_key "${1}")"}" 471 | local name="${2:-"$(infer_key_name "${access_url}")"}" 472 | 473 | jq --arg name "${name}" --arg url "${1}" --arg access_url "${access_url}" ' 474 | ($name|ascii_downcase) as $q | 475 | (.keys // []) as $k | 476 | ( 477 | first($k[] | select((.id == $q) or ((.name|ascii_downcase) == $q))).id? // 478 | first(range(1;$k|max_by(.id).id + 2) | select(. as $i | $k | all(.id != $i))) 479 | ) as $id | 480 | .keys |= ([ 481 | $k | map(select(.id != $id)) | .[], 482 | { id: $id, name: $name, url: $url, access_url: $access_url } 483 | ] | sort_by(.id)) 484 | ' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}" 485 | } 486 | 487 | ################################################# 488 | # Updates an existing access key entry in storage 489 | # with the provided key details, replacing any 490 | # existing entry with the same ID. 491 | # Arguments: 492 | # $1. The access key entry to update. 493 | # Returns: 494 | # 0 if the access key was updated; 495 | # otherwise, a non-zero status. 496 | ################################################# 497 | update_key() { 498 | jq --argjson key "${1}" ' 499 | .keys |= ([. | map(select(.id != $key.id)) | .[], $key] | sort_by(.id)) 500 | ' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}" 501 | } 502 | 503 | ################################################# 504 | # Removes the access key from the storage using 505 | # its name or index. 506 | # Arguments: 507 | # $1. The name/index of the key to remove. 508 | # Returns: 509 | # 0 if the access key was removed; 510 | # otherwise, a non-zero status. 511 | ################################################# 512 | remove_key() { 513 | jq --arg query "${1}" '( 514 | (try ($query|tonumber) catch ($query|ascii_downcase)) as $q | 515 | (.keys|length) as $l | 516 | (.keys|map(select((.id != $q) and ((.name|ascii_downcase) != $q)))) as $k | 517 | .keys |= $k, 518 | (null | halt_error(if ($l == ($k|length)) then 1 else 0 end)) 519 | )' "${CONFIG_FILENAME}" | overwrite "${CONFIG_FILENAME}" 520 | } 521 | 522 | ################################################# 523 | # Lists all access keys stored in the storage. 524 | # Arguments: 525 | # $1="%index% %name% %ip%". The format to use 526 | # when printing the access keys. 527 | # Outputs: 528 | # Writes the formatted access key entries 529 | # to stdout. 530 | ################################################# 531 | list_keys() { 532 | local sep="" 533 | format_keys "${1:-"%index%${sep}%name%${sep}%ip%"}" "${CONFIG_FILENAME}" | { 534 | [ -z "${1}" ] && column -s "${sep}" -t || cat - 535 | } 536 | } 537 | 538 | ################################################# 539 | # Gets the status of the current connection. 540 | # Arguments: 541 | # None 542 | # Outputs: 543 | # Writes the id of the relevant access key, 544 | # if any, to stdout. 545 | # Returns: 546 | # 0 if there is an active connection; 547 | # 1 if there is no active connection; 548 | # 2 if the connection is temporarily suspended. 549 | ################################################# 550 | get_status() { 551 | jq -r '( 552 | .key_id // "", 553 | (if (.key_id|type) == "number" then .status|ascii_downcase else "" end) as $s | 554 | null | halt_error(["connected", "disconnected", "suspended"] | index($s) // 1) 555 | )' "${STATE_FILENAME}" 556 | } 557 | 558 | ################################################# 559 | # Generates iptables and ip rules for setting or 560 | # removing routing rules specified by the config. 561 | # Arguments: 562 | # $1="+". The action to perform: 563 | # "+" to set the rules. 564 | # "-" to unset the rules. 565 | # Outputs: 566 | # Writes the iptables and ip rules to stdout. 567 | ################################################# 568 | get_routing_rules() { 569 | if ! command_exists "ip" || ! command_exists "iptables"; then 570 | return 1 571 | fi 572 | 573 | local main_table="$([ -n "$(ip rule show table main 2> /dev/null)" ] && echo "main" || echo "default")" 574 | jq -r --arg action "${1:-"+"}" --arg main_table "${main_table}" ' 575 | (if ($action == "+") then ["add", "-A"] else ["delete", "-D"] end) as [$ipr, $ipt] | 576 | .exclude[]? 577 | | select(type == "string") 578 | | select(test("^!?((:\\d+)|((\\d{1,3}\\.){3}\\d{1,3}(/\\d{1,2})?)|(([a-fA-F0-9]{0,4}:){1,7}[a-fA-F0-9]{0,4}(/\\d{1,3})?))$")) 579 | | (if (startswith("!")) then ["233", 23331] else [$main_table, 23332] end) as [$table, $priority] 580 | | sub("^!";"") 581 | | if (startswith(":")) then 582 | ( 583 | "iptables -t mangle \($ipt) OUTPUT -p tcp --sport \(.[1:]|@sh) -j MARK --set-mark \(.[1:]|@sh)", 584 | "iptables -t mangle \($ipt) OUTPUT -p udp --sport \(.[1:]|@sh) -j MARK --set-mark \(.[1:]|@sh)", 585 | "ip rule \($ipr) fwmark \(.[1:]|@sh) table \($table|@sh) priority \($priority|@sh)" 586 | ) 587 | else 588 | "ip rule \($ipr) from all to \(@sh) table \($table|@sh) priority \($priority|@sh)" 589 | end 590 | | if ($action == "+") then . else "while \(.) 2> /dev/null; do :; done" end 591 | ' "${CONFIG_FILENAME}" 592 | } 593 | 594 | ################################################# 595 | # Terminates or forcefully kills a process 596 | # by its full name. 597 | # Arguments: 598 | # $1. The name of the process to terminate. 599 | # $2="0.05". The interval to wait between 600 | # termination attempts (in seconds). 601 | # $3="50". The maximum number of termination 602 | # attempts. 603 | ################################################# 604 | terminate_or_kill() { 605 | # Terminate the process gracefully using SIGTERM. 606 | pkill -TERM -x "${1}" 607 | 608 | # Loop until the process is terminated or maximum attempts reached. 609 | local termination_attempt=0 610 | while pgrep -x "${1}" > /dev/null && [ "${termination_attempt}" -lt "${3:-50}" ]; do 611 | sleep "${2:-"0.05"}" 612 | termination_attempt=$((${termination_attempt} + 1)) 613 | done 614 | 615 | # If process still exists, forcefully kill it using SIGKILL. 616 | pkill -KILL -x "${1}" && pidwait -x "${1}" 617 | } 618 | 619 | ################################################# 620 | # Disconnects from the currently active server, 621 | # if any. Otherwise, does nothing. 622 | # Arguments: 623 | # $1="". A flag indicating wether a connection 624 | # should only be temporarily suspended. 625 | # $2="%name%". A format to use when printing 626 | # the access key used to establish the 627 | # connection. 628 | # Outputs: 629 | # Writes the name of the access key used to 630 | # establish the connection, if any, to stdout. 631 | # Returns: 632 | # 0 if there was an active connection; 633 | # otherwise, a non-zero status. 634 | ################################################# 635 | disconnect() { 636 | local key_id; key_id="$(get_status)" 637 | local status_id=$? 638 | local key_entry="$(get_key_entry "${key_id}")" 639 | local status_name="disconnected" 640 | [ -n "${1}" ] && [ "${status_id}" -ne 1 ] && status_name="suspended" 641 | 642 | terminate_or_kill __vpn_connect 643 | eval "$(get_routing_rules -)" 644 | 645 | jq -n --arg status "${status_name}" --arg id "${key_id}" '{ 646 | status: $status, 647 | key_id: (try ($id|tonumber) catch (null)) 648 | }' > "${STATE_FILENAME}" 649 | [ -n "${key_entry}" ] && format_key "${2:-"%name%"}" "${key_entry}" 650 | } 651 | 652 | ################################################# 653 | # Connects to a Shadowsocks server using 654 | # the specified access key. 655 | # If no name is specified, tries to reconnect to 656 | # the last server, if any. 657 | # Arguments: 658 | # $1="". The name of the access key to use. 659 | # $2="%name%". A format to use when printing 660 | # the access key used to establish the 661 | # connection. 662 | # Outputs: 663 | # Writes the name of the access key used to 664 | # establish the connection, if any, to stdout. 665 | # Otherwise, prints an error to stderr. 666 | # Returns: 667 | # 0 if the connection has been established; 668 | # otherwise, a non-zero status. 669 | ################################################# 670 | connect() { 671 | local key_id="${1:-"$(get_status)"}" 672 | local key_entry="$(get_key_entry "${key_id:-1}")" 673 | if [ -z "${key_entry}" ]; then 674 | error "unknown key: ${key_id}" 675 | return 1 676 | fi 677 | 678 | local key_url="$(jq -rn --argjson key "${key_entry}" '$key.access_url // $key.url')" 679 | local access_url="$(normalize_key "${key_url}")" 680 | if [ "${key_url}" != "${access_url}" ]; then 681 | key_entry="$(jq -cn --argjson key "${key_entry}" --arg url "${access_url}" '$key | .access_url |= $url')" 682 | update_key "${key_entry}" 683 | fi 684 | 685 | disconnect > /dev/null 2>& 1 686 | /usr/local/bin/__vpn_connect -transport "${access_url}" > "${LOG_FILENAME}" 2>& 1 & 687 | local connection_id=$! 688 | sleep 1 689 | 690 | if ps -p "${connection_id}" > /dev/null 2>& 1; then 691 | eval "$(get_routing_rules +)" 692 | jq -n --argjson key "${key_entry}" '{ status: "connected", key_id: $key.id }' > "${STATE_FILENAME}" 693 | format_key "${2:-"%name%"}" "${key_entry}" 694 | else 695 | error "$(tail -n 1 "${LOG_FILENAME}" | sed 's/^\[ERROR\][ 0-9/:]*//')" 696 | fi 697 | } 698 | 699 | ################################################# 700 | # Executes an action by its name. 701 | # Arguments: 702 | # $1. The name of the action to execute. 703 | # $2="". The first action argument. 704 | # $3="". The second action argument. 705 | # Outputs: 706 | # Writes all the relevant output to stdout. 707 | # Writes all errors to stderr. 708 | # Returns: 709 | # 0 if the action was successfully executed; 710 | # otherwise, a non-zero status. 711 | ################################################# 712 | execute_action() { 713 | case "${1}" in 714 | add) 715 | add_key "${2}" "${3}" || error "invalid key: ${2}" 716 | ;; 717 | 718 | remove) 719 | remove_key "${2}" || error "unknown key: ${2}" 720 | ;; 721 | 722 | list) 723 | list_keys "${2}" 724 | ;; 725 | 726 | connect) 727 | connect "${2}" "${NOTIFICATION_BODY_CONNECTED}" 728 | ;; 729 | 730 | disconnect) 731 | disconnect "${2}" "${NOTIFICATION_BODY_DISCONNECTED}" 732 | ;; 733 | 734 | toggle) 735 | if get_status > /dev/null; then 736 | disconnect "" "${NOTIFICATION_BODY_DISCONNECTED}" 737 | else 738 | connect "" "${NOTIFICATION_BODY_CONNECTED}" 739 | fi 740 | ;; 741 | 742 | status) 743 | local key_id; key_id="$(get_status)" 744 | local status_id=$? 745 | local key_entry="$(get_key_entry "${key_id}")" 746 | 747 | if [ "${status_id}" -eq 0 ] && [ -n "${key_entry}" ]; then 748 | format_key "${NOTIFICATION_BODY_CONNECTED}" "${key_entry}" 749 | elif [ -n "${key_entry}" ]; then 750 | format_key "${NOTIFICATION_BODY_DISCONNECTED}" "${key_entry}" 751 | else 752 | echo "${NOTIFICATION_BODY_DISCONNECTED_UNKNOWN}" 753 | fi 754 | return "${status_id}" 755 | ;; 756 | 757 | *) return 1 ;; 758 | esac 759 | } 760 | 761 | ################################################# 762 | # Prints version information. 763 | # Arguments: 764 | # None 765 | # Outputs: 766 | # Writes version information to stdout. 767 | ################################################# 768 | version() { 769 | echo "vpn-manager ${APP_VERSION}" 770 | } 771 | 772 | ################################################# 773 | # Prints a brief help message. 774 | # Arguments: 775 | # None 776 | # Outputs: 777 | # Writes the help message to stdout. 778 | ################################################# 779 | help() { 780 | echo "Usage: ${0} [] []" 781 | echo 782 | echo "Manage Shadowsocks server connections and related access keys." 783 | echo 784 | echo "Examples:" 785 | echo " sudo ${0} add \"ss://...\" \"Geneva\"" 786 | echo " sudo ${0} connect geneva" 787 | echo " sudo ${0} disconnect" 788 | echo 789 | echo "Commands:" 790 | echo " add [] Add a new access key" 791 | echo " remove Remove the designated access key" 792 | echo " list [-f ] List all stored access keys" 793 | echo " connect [] Connect to a server" 794 | echo " disconnect [-s] Disconnect from the current server" 795 | echo " toggle Toggle the current connection" 796 | echo " status Return the current connection status" 797 | echo 798 | echo "Options:" 799 | echo " -h, --help Display this help text and exit" 800 | echo " -v, --version Display version information and exit" 801 | echo " -q, --quiet Suppress all normal output" 802 | echo " -n, --notify Display a notification" 803 | echo " -s, --suspend Suspend the current connection;" 804 | echo " It will be re-established later" 805 | echo " -f, --format Print a key according to the ;" 806 | echo " The formats are: %name%, %ip%, %index%" 807 | } 808 | 809 | ################################################# 810 | # Formats and prints the provided error message, 811 | # displays the help page, and terminates the 812 | # process. 813 | # Arguments: 814 | # $1. The error message to format and print. 815 | # Outputs: 816 | # Writes the formatted error message to stderr. 817 | # Returns: 818 | # Never returns (exits with a status of 1). 819 | ################################################# 820 | fatal_error() { 821 | error "${1}" 822 | help >& 2 823 | exit 1 824 | } 825 | 826 | ################################################# 827 | # The main entry point for the script. 828 | # Arguments: 829 | # ... A list of the command line arguments. 830 | ################################################# 831 | main() { 832 | local quiet="" 833 | local notify="" 834 | local suspend="" 835 | local format="" 836 | local action="" 837 | local arg0="" 838 | local arg1="" 839 | 840 | # Parse the arguments and options. 841 | while [ -n "${1}" ]; do 842 | case "${1}" in 843 | -h|--help) help; exit 0 ;; 844 | -v|--version) version; exit 0 ;; 845 | -q|--quiet) quiet="-q" ;; 846 | -n|--notify) notify="-n" ;; 847 | -s|--suspend) suspend="-s" ;; 848 | -f|--format) format="${2}"; shift ;; 849 | -*) fatal_error "invalid option: ${1}" ;; 850 | *) 851 | if [ -z "${action}" ]; then 852 | action="${1}" 853 | elif [ -z "${arg0}" ]; then 854 | arg0="${1}" 855 | elif [ -z "${arg1}" ]; then 856 | arg1="${1}" 857 | else 858 | fatal_error "invalid argument: ${1}" 859 | fi 860 | ;; 861 | esac 862 | shift 2> /dev/null 863 | done 864 | 865 | # Initialize everything we need. 866 | init_log 867 | init_state 868 | init_config 869 | init_notifications 870 | 871 | # Validate the parsed arguments and normalize the action name. 872 | case "${action}" in 873 | add|a) 874 | action="add" 875 | [ -n "${arg0}" ] || fatal_error "missing argument: " 876 | ;; 877 | 878 | remove|rm|r) 879 | action="remove" 880 | [ -n "${arg0}" ] || fatal_error "missing argument: " 881 | ;; 882 | 883 | list|ls|l) action="list" ;; 884 | 885 | connect|cd|c) 886 | action="connect" 887 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}" 888 | ;; 889 | 890 | disconnect|exit|d) 891 | action="disconnect" 892 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_DISCONNECTED}" 893 | ;; 894 | 895 | toggle|t) 896 | action="toggle" 897 | if get_status > /dev/null; then 898 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_DISCONNECTED}" 899 | else 900 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}" 901 | fi 902 | ;; 903 | 904 | status|s) 905 | action="status" 906 | NOTIFICATION_ICON_SUCCESS="${NOTIFICATION_ICON_CONNECTED}" 907 | NOTIFICATION_ICON_FAILURE="${NOTIFICATION_ICON_DISCONNECTED}" 908 | ;; 909 | 910 | *) fatal_error "invalid command: ${1}" ;; 911 | esac 912 | 913 | # Everything we do requires superuser privileges. 914 | # So, there is no real reason to proceed without those. 915 | assert_is_root 916 | 917 | # Redirect the output to a variable, so we can decide how to display it later. 918 | local action_result; action_result="$(execute_action "${action}" "${arg0:-"${format:-"${suspend}"}"}" "${arg1}" 2>& 1)" 919 | local action_code=$? 920 | 921 | # Display the output. 922 | # Either via a notification if the "--notify" flag has been provided, or 923 | # just write it back to stdout. 924 | if [ -n "${action_result}" ]; then 925 | if [ -n "${notify}" ]; then 926 | local action_icon="$([ "${action_code}" -eq 0 ] && echo "${NOTIFICATION_ICON_SUCCESS}" || echo "${NOTIFICATION_ICON_FAILURE}")" 927 | send_notification "${NOTIFICATION_TITLE}" "${action_result}" "${action_icon}" 928 | elif [ -z "${quiet}" ]; then 929 | echo "${action_result}" 930 | fi 931 | fi 932 | exit "${action_code}" 933 | } 934 | 935 | main "${@}" 936 | --------------------------------------------------------------------------------