123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422 |
- // License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
- package at
- import (
- "bytes"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "reflect"
- "strconv"
- "strings"
- "time"
- "unicode/utf16"
- "golang.org/x/sys/unix"
- "kitty"
- "kitty/tools/cli"
- "kitty/tools/crypto"
- "kitty/tools/tty"
- "kitty/tools/tui"
- "kitty/tools/tui/loop"
- "kitty/tools/utils"
- "kitty/tools/utils/base85"
- "kitty/tools/utils/shlex"
- )
- const lowerhex = "0123456789abcdef"
- var ProtocolVersion [3]int = [3]int{0, 26, 0}
- type password struct {
- val string
- is_set bool
- }
- type GlobalOptions struct {
- to_network, to_address string
- password password
- to_address_is_from_env_var bool
- already_setup bool
- }
- var global_options GlobalOptions
- func expand_ansi_c_escapes_in_args(args ...string) (escaped_string, error) {
- for i, x := range args {
- args[i] = shlex.ExpandANSICEscapes(x)
- }
- return escaped_string(strings.Join(args, " ")), nil
- }
- func escape_list_of_strings(args []string) []escaped_string {
- ans := make([]escaped_string, len(args))
- for i, x := range args {
- ans[i] = escaped_string(x)
- }
- return ans
- }
- func set_payload_string_field(io_data *rc_io_data, field, data string) {
- payload_interface := reflect.ValueOf(&io_data.rc.Payload).Elem()
- struct_in_interface := reflect.New(payload_interface.Elem().Type()).Elem()
- struct_in_interface.Set(payload_interface.Elem()) // copies the payload to struct_in_interface
- struct_in_interface.FieldByName(field).SetString(data)
- payload_interface.Set(struct_in_interface) // copies struct_in_interface back to payload
- }
- func get_pubkey(encoded_key string) (encryption_version string, pubkey []byte, err error) {
- if encoded_key == "" {
- encoded_key = os.Getenv("KITTY_PUBLIC_KEY")
- if encoded_key == "" {
- err = fmt.Errorf("Password usage requested but KITTY_PUBLIC_KEY environment variable is not available")
- return
- }
- }
- encryption_version, encoded_key, found := strings.Cut(encoded_key, ":")
- if !found {
- err = fmt.Errorf("KITTY_PUBLIC_KEY environment variable does not have a : in it")
- return
- }
- if encryption_version != kitty.RC_ENCRYPTION_PROTOCOL_VERSION {
- err = fmt.Errorf("KITTY_PUBLIC_KEY has unknown version, if you are running on a remote system, update kitty on this system")
- return
- }
- pubkey = make([]byte, base85.DecodedLen(len(encoded_key)))
- n, err := base85.Decode(pubkey, []byte(encoded_key))
- if err == nil {
- pubkey = pubkey[:n]
- }
- return
- }
- type escaped_string string
- func (s escaped_string) MarshalJSON() ([]byte, error) {
- // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON
- // we additionally escape all non-ascii chars so they can be safely transmitted inside an escape code
- src := utf16.Encode([]rune(s))
- buf := make([]byte, 0, len(src)+128)
- a := func(x ...byte) {
- buf = append(buf, x...)
- }
- a('"')
- for _, r := range src {
- if ' ' <= r && r <= 126 {
- if r == '\\' || r == '"' {
- buf = append(buf, '\\')
- }
- buf = append(buf, byte(r))
- continue
- }
- switch r {
- case '\n':
- a('\\', 'n')
- case '\t':
- a('\\', 't')
- case '\r':
- a('\\', 'r')
- case '\f':
- a('\\', 'f')
- case '\b':
- a('\\', 'b')
- default:
- a('\\', 'u')
- for s := 12; s >= 0; s -= 4 {
- a(lowerhex[r>>uint(s)&0xF])
- }
- }
- }
- a('"')
- return buf, nil
- }
- func simple_serializer(rc *utils.RemoteControlCmd) (ans []byte, err error) {
- return json.Marshal(rc)
- }
- type serializer_func func(rc *utils.RemoteControlCmd) ([]byte, error)
- func create_serializer(password password, encoded_pubkey string, io_data *rc_io_data) (err error) {
- io_data.serializer = simple_serializer
- if password.is_set {
- encryption_version, pubkey, err := get_pubkey(encoded_pubkey)
- if err != nil {
- return err
- }
- io_data.serializer = func(rc *utils.RemoteControlCmd) (ans []byte, err error) {
- ec, err := crypto.Encrypt_cmd(rc, global_options.password.val, pubkey, encryption_version)
- if err != nil {
- return
- }
- return json.Marshal(ec)
- }
- if io_data.timeout < 120*time.Second {
- io_data.timeout = 120 * time.Second
- }
- }
- return nil
- }
- type ResponseData struct {
- as_str string
- is_string bool
- }
- func (self *ResponseData) UnmarshalJSON(data []byte) error {
- if bytes.HasPrefix(data, []byte("\"")) {
- self.is_string = true
- return json.Unmarshal(data, &self.as_str)
- }
- if bytes.Equal(data, []byte("true")) {
- self.as_str = "True"
- } else if bytes.Equal(data, []byte("false")) {
- self.as_str = "False"
- } else {
- self.as_str = string(data)
- }
- return nil
- }
- type Response struct {
- Ok bool `json:"ok"`
- Data ResponseData `json:"data,omitempty"`
- Error string `json:"error,omitempty"`
- Traceback string `json:"tb,omitempty"`
- }
- type rc_io_data struct {
- cmd *cli.Command
- rc *utils.RemoteControlCmd
- serializer serializer_func
- on_key_event func(lp *loop.Loop, ke *loop.KeyEvent) error
- string_response_is_err bool
- handle_response func(data []byte) error
- timeout time.Duration
- multiple_payload_generator func(io_data *rc_io_data) (bool, error)
- chunks_done bool
- }
- func (self *rc_io_data) next_chunk() (chunk []byte, err error) {
- if self.chunks_done {
- return make([]byte, 0), nil
- }
- if self.multiple_payload_generator != nil {
- is_last, err := self.multiple_payload_generator(self)
- if err != nil {
- return nil, err
- }
- if is_last {
- self.chunks_done = true
- }
- return self.serializer(self.rc)
- }
- self.chunks_done = true
- return self.serializer(self.rc)
- }
- func get_response(do_io func(io_data *rc_io_data) ([]byte, error), io_data *rc_io_data) (ans *Response, err error) {
- serialized_response, err := do_io(io_data)
- if err != nil {
- if errors.Is(err, os.ErrDeadlineExceeded) && io_data.rc.Async != "" {
- io_data.rc.Payload = nil
- io_data.rc.CancelAsync = true
- io_data.multiple_payload_generator = nil
- io_data.rc.NoResponse = true
- io_data.chunks_done = false
- _, _ = do_io(io_data)
- err = fmt.Errorf("Timed out waiting for a response from kitty")
- }
- return nil, err
- }
- if len(serialized_response) == 0 {
- if io_data.rc.NoResponse {
- res := Response{Ok: true}
- ans = &res
- return
- }
- err = fmt.Errorf("Received empty response from kitty")
- return
- }
- var response Response
- err = json.Unmarshal(serialized_response, &response)
- if err != nil {
- err = fmt.Errorf("Invalid response received from kitty, unmarshalling error: %w", err)
- return
- }
- ans = &response
- return
- }
- var running_shell = false
- type exit_error struct {
- exit_code int
- }
- func (m *exit_error) Error() string {
- return fmt.Sprintf("Subprocess exit with code: %d", m.exit_code)
- }
- func send_rc_command(io_data *rc_io_data) (err error) {
- err = setup_global_options(io_data.cmd)
- if err != nil {
- return err
- }
- wid, err := strconv.Atoi(os.Getenv("KITTY_WINDOW_ID"))
- if err == nil && wid > 0 {
- io_data.rc.KittyWindowId = uint(wid)
- }
- err = create_serializer(global_options.password, "", io_data)
- if err != nil {
- return
- }
- var response *Response
- response, err = get_response(utils.IfElse(global_options.to_network == "", do_tty_io, do_socket_io), io_data)
- if err != nil || response == nil {
- return
- }
- if !response.Ok {
- if response.Traceback != "" {
- fmt.Fprintln(os.Stderr, response.Traceback)
- }
- return fmt.Errorf("%s", response.Error)
- }
- if io_data.handle_response != nil {
- return io_data.handle_response(utils.UnsafeStringToBytes(response.Data.as_str))
- }
- if response.Data.is_string && io_data.string_response_is_err {
- return fmt.Errorf("%s", response.Data.as_str)
- }
- if response.Data.as_str != "" {
- fmt.Println(strings.TrimRight(response.Data.as_str, "\n \t"))
- }
- return
- }
- func get_password(password string, password_file string, password_env string, use_password string) (ans password, err error) {
- if use_password == "never" {
- return
- }
- if password != "" {
- ans.is_set, ans.val = true, password
- }
- if !ans.is_set && password_file != "" {
- if password_file == "-" {
- if tty.IsTerminal(os.Stdin.Fd()) {
- p, err := tui.ReadPassword("Password: ", true)
- if err != nil {
- return ans, err
- }
- ans.is_set, ans.val = true, p
- } else {
- var q []byte
- q, err = io.ReadAll(os.Stdin)
- if err == nil {
- ans.is_set, ans.val = true, strings.TrimRight(string(q), " \n\t")
- }
- ttyf, err := os.Open(tty.Ctermid())
- if err == nil {
- err = unix.Dup2(int(ttyf.Fd()), int(os.Stdin.Fd())) //nolint ineffassign err is returned indicating duping failed
- ttyf.Close()
- }
- }
- } else if strings.HasPrefix(password_file, "fd:") {
- var fd int
- if fd, err = strconv.Atoi(password_file[3:]); err == nil {
- f := os.NewFile(uintptr(fd), password_file)
- var q []byte
- if q, err = io.ReadAll(f); err == nil {
- ans.is_set = true
- ans.val = string(q)
- }
- f.Close()
- }
- } else {
- var q []byte
- q, err = os.ReadFile(password_file)
- if err == nil {
- ans.is_set, ans.val = true, strings.TrimRight(string(q), " \n\t")
- } else {
- if errors.Is(err, os.ErrNotExist) {
- err = nil
- }
- }
- }
- if err != nil {
- return
- }
- }
- if !ans.is_set && password_env != "" {
- ans.val, ans.is_set = os.LookupEnv(password_env)
- }
- if !ans.is_set && use_password == "always" {
- ans.is_set = true
- return ans, nil
- }
- if len(ans.val) > 1024 {
- return ans, fmt.Errorf("Specified password is too long")
- }
- return ans, nil
- }
- var all_commands []func(*cli.Command) *cli.Command = make([]func(*cli.Command) *cli.Command, 0, 64)
- func register_at_cmd(f func(*cli.Command) *cli.Command) {
- all_commands = append(all_commands, f)
- }
- func setup_global_options(cmd *cli.Command) (err error) {
- if global_options.already_setup {
- return nil
- }
- err = cmd.GetOptionValues(&rc_global_opts)
- if err != nil {
- return err
- }
- if rc_global_opts.To == "" {
- rc_global_opts.To = os.Getenv("KITTY_LISTEN_ON")
- global_options.to_address_is_from_env_var = true
- }
- if rc_global_opts.To != "" {
- network, address, err := utils.ParseSocketAddress(rc_global_opts.To)
- if err != nil {
- return err
- }
- global_options.to_network = network
- global_options.to_address = address
- }
- q, err := get_password(rc_global_opts.Password, rc_global_opts.PasswordFile, rc_global_opts.PasswordEnv, rc_global_opts.UsePassword)
- global_options.password = q
- global_options.already_setup = true
- return err
- }
- func EntryPoint(tool_root *cli.Command) *cli.Command {
- at_root_command := tool_root.AddSubCommand(&cli.Command{
- Name: "@",
- Usage: "[global options] [sub-command] [sub-command options] [sub-command args]",
- ShortDescription: "Control kitty remotely",
- HelpText: "Control kitty by sending it commands. Set the allow_remote_control option in :file:`kitty.conf` for this to work. When run without any sub-commands this will start an interactive shell to control kitty.",
- Run: shell_main,
- })
- add_rc_global_opts(at_root_command)
- global_options_group := at_root_command.OptionGroups[0]
- for _, reg_func := range all_commands {
- c := reg_func(at_root_command)
- clone := tool_root.AddClone("", c)
- clone.Name = "@" + c.Name
- clone.Hidden = true
- clone.OptionGroups = append(clone.OptionGroups, global_options_group.Clone(clone))
- }
- return at_root_command
- }
|