2018-12-08

О CORS

Современные браузеры не хотят просто так ходить на другие домены. Точнее на другой Origin. Это касается JavaScript, выполняемого в браузере.
Нельзя просто так взять, и потыкать API, находящееся в другом домене. И нельзя сделать crawler, работающий в браузере. Это будут cross-origin запросы. И они запрещены. Из соображений безопасности. Похоже, это скорее забота о том, чтобы конфиденциальные данные из вашего браузера не утекли куда не надо, чем попытка затруднить жизнь тем, кто захочет воспользоваться вашим API.
Но иногда у вас какой-нибудь SPA на React должен ходить в API на другой домен. И тут на помощь приходит CORS. Cross-Origin Resource Sharing. Сервер другого ресурса, нашего API, может явно сказать, с каких Origin к нему разрешён доступ. С помощью некоторых дополнительных HTTP заголовков.
В простейшем случае, когда наш JavaScript делает GET запрос, и не передаёт никаких потенциально опасных заголовков, браузер включает в запрос заголовок Origin. А сервер должен ответить с заголовком Access-Control-Allow-Origin, где, собственно, и говорит, разрешён ли доступ с этого Origin.
CORS Simple Request
В более сложном случае, например, когда мы делаем POST запрос с JSON в теле запроса, браузер делает так называемый "preflight request".
Это OPTIONS запрос, в который включены заголовки, описывающие будущий полноценный запрос. Access-Control-Request-Method говорит, какой HTTP метод будет в будущем запросе. Access-Control-Request-Headers говорит, какие HTTP заголовки будут в будущем запросе (помимо тех, вроде Host, User-Agent и Acept, которые и так разрешены по умолчанию). Конечно же, этот запрос включает заголовок Origin.
Если сервер согласен и с Origin, и с методом, и с заголовками, он отвечает 200 OK с дополнительными заголовками. Снова заголовок Access-Control-Allow-Origin выражает согласие сервера с Origin. Заголовок Access-Control-Allow-Methods перечисляет методы, которые сервер готов принимать с этого Origin. Заголовок Access-Control-Allow-Headers подтверждает готовность сервера принимать заголовки. Заголовок Access-Control-Max-Age указывает срок действия данного соглашения, браузер может какое-то время не повторять preflight запросы.
CORS Prefligh Request
В Access-Control-Allow-Origin сервер может вернуть не только тот самый Origin, что запросил доступ, но и специальное значение "*", звёздочку. Это означает "любой Origin". Но в этом случае запрещается передавать в запросе credentials. Под "credentials" подразумеваются куки, информация об аутентификации, включая заголовки и TLS сертификаты. Всё то, что браузер может неявно подмешать в запрос, и что явно идентифицирует пользователя.
По умолчанию credentials запрещены. Но сервер может разрешить, если выдаст заголовок Access-Control-Allow-Credentials со значением true. Но для Origin "*" это не работает.
Если говорить про fetch() в браузере, то для credentials есть свои опции.
fetch(url, {
    method: "POST", // *GET, POST, PUT, DELETE, etc.
    mode: "cors", // no-cors, cors, *same-origin
    cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
    credentials: "same-origin", // include, *same-origin, omit
    headers: {
        "Content-Type": "application/json; charset=utf-8",
        // "Content-Type": "application/x-www-form-urlencoded",
    },
    redirect: "follow", // manual, *follow, error
    referrer: "no-referrer", // no-referrer, *client
    body: JSON.stringify(data), // body data type must match "Content-Type" header
})
Сервер может ещё использовать заголовок Vary, чтобы указать на заголовки запроса, изменение которых приведёт к другому ответу сервера. Поэтому положено, если Access-Control-Allow-Origin возвращает конкретный Origin, то должен быть ещё и Vary: Origin.
Почти во всех случаях, когда у вас есть SPA и отдельное API, вам придётся учитывать CORS.
Вам повезло, если вы можете закрыть и фронтенд и бэкенд общим прокси на одном домене. Тогда ваше API будет, скажем, просто по относительному URL "/api", и это будет same-origin, и CORS не понадобится.
Вам повезло, если перед бэкендом вы можете поставить прокси, которое сможет добавлять заголовки. Например, Nginx. Тогда он сможет взять на себя всю работу c CORS. Не забудьте включить в Access-Control-Allow-Headers все заголовки, которые рожает ваше SPA.
location /api {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    proxy_pass http://127.0.0.1:8080/;
}
Если вам позарез нужны credentials, например, аутентификация у вас через cookie, то всё немного усложняется.
location /api {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin';
        add_header 'Vary' 'Origin';
        add_header 'Access-Control-Allow-Credentials' 'true';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        add_header 'Content-Length' 0;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Vary' 'Origin';
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Origin,Accept,Key,Keep-Alive,User-Agent,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
    proxy_pass http://127.0.0.1:8080/api;
}
Как правило, на preflight запрос сервер должен отвечать минуя все механизмы авторизации. Собственно, так и произойдёт, если вы к этому location добавите ещё, например, директивы auth_basic. Потому что return прерывает обработку запроса раньше.
Приведённые конфигурации открывают доступ к API с любого Origin. Возможно, это не то, что вы хотите. Тогда разбираться с CORS придётся в самом бэкенде.
Если вы разворачиваетесь в облаке, то вряд ли вам захочется запускать свой Nginx, лучше воспользоваться облачным load balancer. А они вовсе не обязаны втыкать дополнительные заголовки. К тому же в облаках статику и динамику как-то удобнее размещать в разных доменах, CDN, всё такое. Так что от CORS не отвертитесь, и снова на бэкенде.
В Spring Boot можно сделать так:
@Configuration
@EnableConfigurationProperties(CorsProperties::class)
open class CorsConfiguration {

    @Bean
    open fun corsConfigurer(
        corsProperties: CorsProperties?
    ): WebMvcConfigurer {
        return object : WebMvcConfigurer {
            override fun addCorsMappings(registry: CorsRegistry) {
                if (corsProperties != null && corsProperties.allowedOrigins.isNotEmpty()) {
                    registry.addMapping("/api/**").allowedOrigins(*corsProperties.allowedOrigins.toTypedArray())
                }
            }
        }
    }

}

@ConfigurationProperties(prefix = "web.cors")
open class CorsProperties {
    var allowedOrigins: List<String> = mutableListOf()
}
Тогда разрешить нужные Origin можно в application.yml:
web:
  cors:
    allowed-origins:
      - 'http://localhost:3000'
      - 'http://my.webapp.com'

2018-11-17

Об IPv6

Тихо и незаметно наступает эпоха IPv6. Уже можно заполучить IPv6 дома или в офисе, по крайней мере, клиентам ЭР-Телекома. Уже подключают к интернетам только по IPv6, по крайней мере, некоторых клиентов нашего заказчика, где-то в Америке. Уже без проблем можно получить IPv6 адрес для любого сервера любого уровня виртуальности почти у любого хостера или облачного провайдера. Уже встречаются дешманские виртуалки только с IPv6, и с IPv4 через NAT, где проброшен десяток портов.
ipv6
В IPv4, как помните, адрес 32-битный. Лишь четыре миллиарда адресов. И они закончились ещё в 2011.
В IPv6 адрес уже 128 бит. Этого хватит всем. Всем землянам, по крайней мере. И миллионам их карманных устройств, у каждого.
IPv6 адреса записываются в виде шестнадцатиричных чисел. Восемь четырёхзначных чисел, разделённых двоеточием. Начальные нули в каждом числе можно упустить. Самую длинную группу из нулевых чисел тоже можно упустить. В результате адрес localhost выглядит как ::1.
$ ip addr show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
IPv6 адреса на интерфейсе, глядящем в Интернет, выглядят примерно так:
$ ip -6 addr show dev wlp2s0
2: wlp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
    inet6 fd9e:ecc8:b68d::13f/128 scope global noprefixroute
       valid_lft forever preferred_lft forever
    inet6 2a02:ffff:ffff:12f0::13f/128 scope global dynamic noprefixroute
       valid_lft 85082sec preferred_lft 2282sec
    inet6 fd9e:ecc8:b68d:0:49ba:acb9:11b5:5adb/64 scope global temporary dynamic
       valid_lft 597201sec preferred_lft 78558sec
    inet6 fd9e:ecc8:b68d:0:fcd2:44e9:bb4d:d28b/64 scope global mngtmpaddr noprefixroute
       valid_lft forever preferred_lft forever
    inet6 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64 scope global temporary dynamic
       valid_lft 85081sec preferred_lft 2281sec
    inet6 2a02:ffff:ffff:12f0:c354:cdb3:9794:b0a/64 scope global dynamic mngtmpaddr noprefixroute
       valid_lft 85081sec preferred_lft 2281sec
    inet6 fe80::ff0b:c674:5528:a3c8/64 scope link noprefixroute
       valid_lft forever preferred_lft forever
Да. IPv6 адресов всегда больше одного.
Адрес, начинающийся с fe80::, который scope link, присутствует всегда. Просто, если у вас включен IPv6. Даже если вы никуда не подключены. Этот адрес назначается автоматически, и уникален для данного интерфейса.
Можно считать это неким аналогом адресов 169.254.0.0/16 в IPv4. Только в IPv4 адрес 169.254 выдаётся только если не удалось получить адрес другим способом, например, по DHCP. И на это иногда уходит несколько секунд при загрузке ОС.
А в IPv6 адрес fe80:: назначается сразу, локально. Он конструируется из MAC адреса. И, в результате, в IPv6 хост может сразу общаться с другими IPv6 хостами, правда, в рамках только локальной сети.
Этого вполне достаточно, чтобы запросить публичный маршрутизируемый адрес у ближайшего маршрутизатора. Таким образом, адреса получаются автоматически без привлечения других протоколов. Это называется Router Advertisement.
Адреса раздаются префиксами. Подсетями чудовищного размера с маской /64. Прикиньте, да, 1.8e+19 адресов на абонента.
В данном случае у нас виднеется два префикса.
$ sipcalc fd9e:ecc8:b68d:0:49ba:acb9:11b5:5adb/64
-[ipv6 : fd9e:ecc8:b68d:0:49ba:acb9:11b5:5adb/64] - 0

[IPV6 INFO]
Expanded Address        - fd9e:ecc8:b68d:0000:49ba:acb9:11b5:5adb
Compressed address      - fd9e:ecc8:b68d:0:49ba:acb9:11b5:5adb
Subnet prefix (masked)  - fd9e:ecc8:b68d:0:0:0:0:0/64
Address ID (masked)     - 0:0:0:0:49ba:acb9:11b5:5adb/64
Prefix address          - ffff:ffff:ffff:ffff:0:0:0:0
Prefix length           - 64
Address type            - Unassigned
Network range           - fd9e:ecc8:b68d:0000:0000:0000:0000:0000 -
                          fd9e:ecc8:b68d:0000:ffff:ffff:ffff:ffff
То, что начинается на fd9e:ecc8: — это "Unique Local Unicast" (ULA) адреса. Это аналог 192.168.0.0/16, или 10.xxx.xxx.xxx или 172.16.xxx.xxx. Это немаршрутизируемые адреса, применимые только в локальной сети. В данном случае эти адреса нам зачем-то выдал маршрутизатор на OpenWRT.
$ sipcalc 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64
-[ipv6 : 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64] - 0

[IPV6 INFO]
Expanded Address        - 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb
Compressed address      - 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb
Subnet prefix (masked)  - 2a02:ffff:ffff:12f0:0:0:0:0/64
Address ID (masked)     - 0:0:0:0:49ba:acb9:11b5:5adb/64
Prefix address          - ffff:ffff:ffff:ffff:0:0:0:0
Prefix length           - 64
Address type            - Aggregatable Global Unicast Addresses
Network range           - 2a02:ffff:ffff:12f0:0000:0000:0000:0000 -
                          2a02:ffff:ffff:12f0:ffff:ffff:ffff:ffff
А вот префикс 2a02:ffff:ffff:12f0::/64 — это уже настоящий публичный префикс, выданный провайдером.
И в этом настоящем префиксе у нас почему-то аж три адреса.
$ ip -6 addr show dev wlp2s0 | grep 2a02
    inet6 2a02:ffff:ffff:12f0::13f/128 scope global dynamic noprefixroute
    inet6 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64 scope global temporary dynamic
    inet6 2a02:ffff:ffff:12f0:c354:cdb3:9794:b0a/64 scope global dynamic mngtmpaddr noprefixroute
Адрес, помеченный mngtmpaddr, — это адрес, автоматически назначенный для данного префикса и данного MAC адреса. Точнее для EUI-64.
$ ipv6calc -i 2a02:ffff:ffff:12f0:c354:cdb3:9794:b0a/64
Address type: unicast, global-unicast, productive, iid, iid-global, iid-eui64
Interface identifier: c354:cdb3:9794:0b0a
EUI-64 identifier: c1:54:cd:b3:97:94:0b:0a
EUI-64 identifier is a global unique one
У этих адресов беда с безопасностью. Вторая половина IPv6 адреса у вашего компьютера будет всегда одной и той же, куда бы вы не перемещались, и какой бы IPv6 префикс вы не получали. Так можно отследить ваши перемещения.
Поэтому генерируется ещё второй адрес, помеченный как temporary. Здесь суффикс уже полностью случайный. Это называется "Privacy Extension" (RFC 3041).
$ ipv6calc -i 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64
Address type: unicast, global-unicast, productive, iid-random, iid, iid-local
Interface identifier: 49ba:acb9:11b5:5adb
Interface identifier is probably generated by privacy extension
Последний адрес выдан DHCPv6. На самом деле, DHCP для IPv6 не особо нужен, и так адреса нормально назначаются. Но иногда, вроде, он нужен. По крайней мере у ЭР-Телекома нужно заполучать префиксы через DHCPv6 внутри PPPoE соединения.
$ ipv6calc -i 2a02:ffff:ffff:12f0::13f/128
Address type: unicast, global-unicast, productive, iid, iid-local
Interface identifier: 0000:0000:0000:013f
Interface identifier is probably manual set
Так под каким адресом мы ходим в интернет? В таблице маршрутизации всё запутанно.
$ ip -6 route show dev wlp2s0
2a02:ffff:ffff:12f0::13f proto kernel metric 600 pref medium
2a02:ffff:ffff:12f0::/64 proto ra metric 600 pref medium
fd9e:ecc8:b68d::13f proto kernel metric 600 pref medium
fd9e:ecc8:b68d::/64 proto ra metric 600 pref medium
fd9e:ecc8:b68d::/48 via fe80::c66e:1fff:feb9:e41b proto ra metric 600 pref medium
fe80::/64 proto kernel metric 256 pref medium
fe80::/64 proto kernel metric 600 pref medium
default via fe80::c66e:1fff:feb9:e41b proto ra metric 600 pref medium
Тут видно, что адрес машрутизатора (via) указан в виде локального адреса fe80::. Локальная сеть, оказывается, имеет большой префикс /48: fd9e:ecc8:b68d::/48. Адреса, полученные по DHCPv6, помечены как proto kernel. А адреса, полученные через Router Advertising, помечены как proto ra. И default маршрут тоже proto ra.
Выходит, что Router Advertising вроде как предпочтительнее DHCPv6. А temporary адрес, который случайный, должен быть предпочтительнее. И действительно, Яндекс Интернетометр говорит, что наш IPv6 адрес — 2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb. Собственно, остальные адреса и помечены как noprefixroute, то есть с ними не связаны маршруты.
Похоже, моя домашнаяя сеть — это какой-то IPv6 ад. На серваках всё проще.
# ip -6 addr show dev eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qlen 1000
    inet6 2001:ffff:ffff:19c:5400:ff:fe54:a41/64 scope global mngtmpaddr dynamic
       valid_lft 2591971sec preferred_lft 604771sec
    inet6 fe80::5400:ff:fe54:a41/64 scope link
       valid_lft forever preferred_lft forever
Тут нет DHCPv6. Тут нет нужды в Privacy Extension, потому что серваки не ездят по планете. В результате у нас только два адреса: автоматический локальный fe80:: и настоящий публичный IPv6.
$ ip -6 route show dev eth0
2001:ffff:ffff:19c::/64  proto kernel  metric 256  expires 2591828sec
fe80::/64  proto kernel  metric 256
default via fe80::fc00:ff:fe54:a41  proto ra  metric 1024  expires 1628sec hoplimit 64
С маршрутами тоже всё просто и понятно.
Сетевые приложения как правило предпочитают IPv6 сокеты. Серверный IPv6 сокет способен принимать подключения как по IPv6, так и по IPv4, не пугайтесь.
# ss -lnpt
State   Recv-Q Send-Q  Local Address:Port  Peer Address:Port
LISTEN  0      128         127.0.0.1:6379             *:*     users:(("redis-server",pid=571,fd=4))
LISTEN  0      128                 *:80               *:*     users:(("nginx",pid=598,fd=6),("nginx",pid=596,fd=6))
LISTEN  0      128                 *:22               *:*     users:(("sshd",pid=555,fd=3))
LISTEN  0      128                 *:443              *:*     users:(("nginx",pid=598,fd=8),("nginx",pid=596,fd=8))
LISTEN  0      128                :::8080            :::*     users:(("java",pid=516,fd=39))
LISTEN  0      128                :::80              :::*     users:(("nginx",pid=598,fd=7),("nginx",pid=596,fd=7))
LISTEN  0      128                :::22              :::*     users:(("sshd",pid=555,fd=4))
LISTEN  0      128                :::443             :::*     users:(("nginx",pid=598,fd=9),("nginx",pid=596,fd=9))
А вот для Nginx нужно явно указать слушание обоих протоколов.
server {
    listen 80 default_server;
    listen 443 ssl default_server;
    listen [::]:80 default_server;
    listen [::]:443 ssl default_server;

    #...
В URL IPv6 адрес положено брать в квадратные скобки: http://[2a00:1450:4011:808::1001].
Для IPv6 адресов в DNS предусмотрен специальный тип записи AAAA.
$ dig +noall +question +answer aaaa google.com
;google.com.                    IN      AAAA
google.com.             299     IN      AAAA    2a00:1450:4011:808::1008
У самих DNS серверов, включая публичные DNS гугла, тоже есть IPv6 адреса.
$ dig +noall +question +answer +stats @2001:4860:4860::8888 aaaa google.com
;google.com.                    IN      AAAA
google.com.             271     IN      AAAA    2a00:1450:4011:80b::1002
;; Query time: 48 msec
;; SERVER: 2001:4860:4860::8888#53(2001:4860:4860::8888)
;; WHEN: Sat Nov 17 19:38:19 +06 2018
;; MSG SIZE  rcvd: 67
$ dig +noall +question +answer +stats @2001:4860:4860::8844 aaaa google.com
;google.com.                    IN      AAAA
google.com.             299     IN      AAAA    2a00:1450:4011:80e::1009
;; Query time: 52 msec
;; SERVER: 2001:4860:4860::8844#53(2001:4860:4860::8844)
;; WHEN: Sat Nov 17 19:39:00 +06 2018
;; MSG SIZE  rcvd: 67
$ dig +noall +question +answer +stats @2606:4700:4700::1111 aaaa google.com
;google.com.                    IN      AAAA
google.com.             188     IN      AAAA    2a00:1450:4011:804::1004
;; Query time: 35 msec
;; SERVER: 2606:4700:4700::1111#53(2606:4700:4700::1111)
;; WHEN: Sat Nov 17 19:40:06 +06 2018
;; MSG SIZE  rcvd: 67
$ dig +noall +question +answer +stats @2606:4700:4700::1001 aaaa google.com
;google.com.                    IN      AAAA
google.com.             157     IN      AAAA    2a00:1450:4011:804::1004
;; Query time: 35 msec
;; SERVER: 2606:4700:4700::1001#53(2606:4700:4700::1001)
;; WHEN: Sat Nov 17 19:40:37 +06 2018
;; MSG SIZE  rcvd: 67
IPv6 вполне поддерживается в соответствующих типах данных PostgreSQL.
postgres=# select network('2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64');
         network
--------------------------
 2a02:ffff:ffff:12f0::/64
(1 строка)

postgres=# select inet '2a02:ffff:ffff:12f0:49ba:acb9:11b5:5adb/64' && cidr '2a02:ffff:ffff:12f0::/64';
 ?column?
----------
 t
(1 строка)
Вроде как существует возможность, имея только IPv6 адрес, ходить в IPv4 сети. Чтобы занатить IPv6 адреса в IPv4, есть NAT64. Чтобы резолвить то, что резолвится только в IPv4, в IPv6 адреса, есть DNS64.
Доля мирового IPv6 трафика уже доходит до 25%. Так что пора, пора приобщаться к новому Интернету. Пока не поздно.
ipv6 adoption
К сожалению, ЭР-Телеком не выдаёт статические IPv6 префиксы. То есть IPv6 вы попробовать сможете, но вот поднять у себя сервер без DynDNS не выйдет. Ну тоже неплохо.
И не забывайте, что IPv6 — это настоящий адрес. Безо всякого NAT. Так что настраивайте файерволы на маршрутизаторах, чтобы ваши телефоны в вайфае не похакали. В OpenWRT по дефолту всё норм, входящие соединения запрещены, получается как за NAT.

2018-10-27

О desktop

Помните ярлыки в Windows. Файлики с расширением .lnk. Классическим фейлом было скопировать на дискетку ярлык вместо самого вордовского файла, а потом удивляться, почему это оно не открывается на другом компьютере.
В наших юниксах, оказывается, тоже есть ярлыки. Freedesktop.org определяет формат файла .desktop, который определяет то, что называется Desktop Entry. Это и ярлыки на рабочем столе, и пункты меню. И это работает и в GNOME, и в KDE, и во всех прочих юниксовых DE. Очень удобно.
Зачем мне ярлыки понадобились? Потому что, как оказалось, в KDE нельзя просто так запустить Java приложение, каким-нибудь java -jar app.jar, а потом закрепить (pin) его в каком-нибудь доке, чтобы потом быстро запускать. Иконка-то появляется, но запускает она просто /usr/bin/java, без параметров. И pin, в результате, не работает.
Чтобы pin заработал, нужно запускать правильный .desktop файл. Он может называться java-RedmineTimeTracker.RedmineTimeTracker.desktop и выглядеть как-то так:
[Desktop Entry]
Type=Application
Name=Redmine Time Tracker
Icon=java-RedmineTimeTracker.RedmineTimeTracker
Exec=java -XX:MaxRAM=256m -jar "/home/gelin/opt/RedmineTimeTracker/Redmine Time Tracker - 0.3.6.jar"
StartupWMClass=RedmineTimeTracker.RedmineTimeTracker
Terminal=false
Categories=Development
Обычный текстовый ini-подобный файл.
Type — это тип. "Application" — это значит, что это "ярлык" для приложения. Ещё есть "Link" и "Directory".
Name — это имя. Как оно будет называться в меню или искаться в dashboard.
Icon — это путь до файла с иконкой. В формате PNG или SVG. Или же имя зарегистрированной иконки. Об этом чуть позже.
Exec — это команда, которая запускает приложение. В данном случае — обычный java -jar, но с ограничением по памяти.
StartupWMClass — это то, ради чего мы вообще этот .desktop файл создавали. В X Window, также как и в Windows, у окна есть класс. Строка. И всякие доки определяют уникальную кнопочку для приложения как раз по этому классу. Из-за отсутствия класса и возникает проблема с Java приложениями. Поэтому мы здесь имя класса и указываем явно.
Есть небольшая проблемка, как определить класс окна для Java приложения. В этом нам поможет программка xprop. Запускаем нужное нам приложение. Запускаем xprop в консоли. Тыкаем по интересующему нас окну. Видим в консоли что-то вроде:
$ xprop | grep WM_CLASS
WM_CLASS(STRING) = "RedmineTimeTracker.RedmineTimeTracker", "RedmineTimeTracker.RedmineTimeTracker"
В данном случае класс совпадает с именем главного класса Java приложения. Я не нашёл, является ли это нормой для десктопных Java приложений. В любом случае, просто прописываем, что обнаружили, в StartupWMClass.
Terminal — флаг, нужно ли запускать приложение в терминале. В данном случае — нет.
Categories — список категорий, через точку с запятой. Это — стандартные категории, на которые делится главное меню. В данном случае это приложение связано с разработкой.
Иконка. Иконка — это ресурс, который можно зарегистрировать в системе, назначить ему имя, и использовать это имя в .desktop файле.
Регистрация производится с помощью программки xdg-icon-resource. XDG — это "X Desktop Group", так раньше назывался freedesktop.org. Сама эта программка — даже не программка, а здоровенный шелловый скрипт, внутри которого есть функции с интересными названиями вроде detectDE(), find_gtk_update_icon_cache() или need_kde_icon_path(). Этот скриптик очень много знает про всякие разные desktop environment.
$ xdg-icon-resource install \
--size 128 \
redmine_logo_green.png \
"java-RedmineTimeTracker.RedmineTimeTracker"
Обязательно нужно указать размер иконки. Больше — лучше. Но не все размеры ваша DE сможет съесть. Хорошо работает 128 и 256 пикселей (128x128 и 256x256).
Последний параметр — имя иконки. Оно обязательно должно состоять из двух слов. Первое, до минуса — это вендор. В данном случае это Java приложение, так что пусть будет "java". Второе, после минуса — собственно идентификатор иконки, для данного вендора. Так как нужен уникальный идентификатор, удобно использовать тут имя класса окна.
На самом деле xdg-icon-resource копирует иконку в каталог вроде ~/.local/share/hicolor/128x128/apps/, или другой, специфичный для вашей DE, и делает магию по обновлению кэша иконок DE.
Сам .desktop файл "устанавливается" тоже своей командой xdg-desktop-menu. Это тоже здоровенный шелловый скрипт.
$ xdg-desktop-menu install \
"java-RedmineTimeTracker.RedmineTimeTracker.desktop"
Имя файла тоже должно содержать префикс вендора, то есть "java-" в данном случае. Скрипт копирует ваш .desktop файл куда-нибудь в ~/.local/share/applications/ и магическим образом обновляет список приложений в DE.
Берём xprop, определяем класс окна. Берём иконку и засовываем её как системный ресурс с помощью xdg-icon-resource. Пишем .desktop файл и добавляем его в систему с помощью xdg-desktop-menu. Всё. Теперь мы умеем создавать "ярлыки" для любых приложений и засовать их в доки и меню.
freedesktop.org logo
И тут выходит Chrome 70. Где отломали одну очень полезную фичу, которой я интенсивно пользовался.
Вы знали, что в Хроме можно было сделать: "Open Main menu -> More Tools -> Add to Desktop"? В результате создавался тот самый .desktop файл, который можно закрепить в доке, и который запускал тот самый сайт, который вы таким образом добавили на рабочий стол, в отдельном окне, без табов и адресной строки.
То есть любой сайт можно "запускать" как обычное приложение. С отдельной кнопкой на панели задач. С нормальным переключением между окнами по Alt+Tab. И, так как технически это просто вкладка того же Хрома, только запущенная в отдельном окне, это жрёт заметно меньше памяти, чем Electron приложение того же Slack (потому что в каждом Электроне — своя копия движка браузера). У меня так запущены кучка Слаков, Gmail, DevDocs и прочие. Мне — очень удобно.
Так вот, в Chrome 70 этот пункт меню убрали. А у сохранившихся ярлыков какого-то чёрта поломалось оформление окон. Теперь они отображаются с хромовыми заголовками окон, что категорически не совпадает по стилю и расположению кнопок с окнами моего KDE.
Но мы же теперь умеем делать .desktop файлы. А у Chrome всё еще остался ключик командной строки --app, который делает то, что нам нужно. То есть запускает указанный url в отдельном окне без табов и адресной строки, и с правильным оформлением самого окна.
Так что берём своё счастье в свои руки. Качаем иконку сайта и засовываем её в систему с помощью xdg-icon-resource. Берите только иконку 256x256, а не 192x192, не все размеры DE съест.
Пишем .desktop файл. Например, chrome-devdocs.io.desktop:
[Desktop Entry]
Type=Application
Name=DevDocs API Documentation
Icon=chrome-devdocs.io
Exec=/opt/google/chrome/google-chrome --profile-directory=Default --app=https://devdocs.io
StartupWMClass=devdocs.io
Terminal=false
Categories=Network
В качестве вендора я тут взял "chrome-", по аналогии со старыми ярлыками. В качестве класса окна Хром выставляет доменное имя из указанного url. Остальное, думаю, понятно.
Устанавливаем файл с помощью xdg-desktop-menu. И наслаждаемся новым удобным приложением, которое на самом деле сайт.
P.S. Я это дело даже немного автоматизировал.

2018-10-14

Об ssh

Об эс-эс-эйч. Которая SSH. Которая Secure SHell. Не все, оказывается, толком представляют, что это такое. И уж тем более не подозревают обо всей мощи SSH.
Впервые я узнал об этой аббревиатуре, "SSH", из журнала "Byte" из 1990-х. В рамках курса английского в универе мы переводили эти исторические статьи. И вот в одной из них встретилась фраза вроде "he sshed to the remote server". Да, "to ssh" — это ещё и глагол.
SSH
SSH появился в 1995 усилиями ещё одного талантливого финна. Его звали Tatu Ylönen.
SSH — это сетевой протокол, работающий поверх TCP. Соответственно, у нас есть сервер, демон sshd, который слушает порт 22, и клиент ssh. У 22 порта есть своя история. Интернет тогда был маленький, и застолбить свой красивый привилегированный порт тогда было довольно просто.
Первая буква "S" в "SSH" действительно означает "Security". SSH возник именно как замена rlogin, rsh и telnet. Все они тоже позволяли подключиться к удалённому серверу и выполнять там команды, но весь трафик там передавался открытым текстом. А SSH — шифрует (и сжимает).
SSH возник примерно в одно время с первыми вариантами SSL (позднее известный как TLS). Поэтому они используют несколько разные подходы. Как минимум, SSH — это не SSL/TLS. А SSL/TLS — это универсальный способ добавить "S" в кучу других протоколов поверх TCP. Самый известный вариант: HTTPS.
В SSL/TLS публичный ключ сервера (и, опциально, клиента) представлен в виде сертификата, подписанного третьей доверенной стороной. И клиент доверяет серверу, если он доверяет этой третьей стороне. В SSH клиент доверяет каждому серверу индивидуально. При первом подключении пользователю явно задаётся вопрос: а доверяем ли мы этому хосту?
$ ssh [email protected]
The authenticity of host 'host.example.net (1.2.3.4)' can't be established.
ECDSA key fingerprint is SHA256:Xocqb7ZPtfsEmXnTCUOeRaCRMfzH1nbTxDVKp0YJBCA.
Are you sure you want to continue connecting (yes/no)?
Если ответить yes, то отпечаток ключа хоста сохранится в ~/.ssh/known_hosts. И при повторном подключении, если ключ сервера не изменился, вопросов уже не будет. А если изменился, то подключения не будет, потому что это уже другой сервер, если у него другой ключ. Простая и эффективная схема доверия.
Если по данному адресу действительно расположился новый сервер, с новым ключом, вам придётся забыть предыдущий ключ. Такой вот командой:
$ ssh-keygen -R "host.example.net"
# Host host.example.net found: line 497
/home/gelin/.ssh/known_hosts updated.
Original contents retained as /home/gelin/.ssh/known_hosts.old
В SSL/TLS клиент, чаще всего, анонимен. Хотя и может предъявить свой сертификат и ключ. В SSH — никакой анонимности. Клиент обязан сказать, от имени какого пользователя (локального пользователя данного сервера) он собирается действовать, предъявить ключ, а там, возможно, ещё и ввести пароль этого самого пользователя.
В простейшем случае мы подключаемся от root и вводим пароль root.
$ ssh [email protected]
[email protected]'s password:
Конечно, не надо так. Подключаться прямо к root, конечно, удобнее. Но подход Ubuntu, когда у root вообще нет пароля, и им вообще нельзя войти в систему, немного безопаснее. Сначала надо подключиться/войти под каким-нибудь "ubuntu". А потом всякие гадости можно делать через sudo. При этом понадобится ввести пароль этого самого "ubuntu".
Ну и, конечно же, не надо подключаться через SSH по паролю. Это ещё хуже, чем прямой root доступ. Потому что пароль можно подобрать. А 4096-битный RSA ключ, ну, почти нельзя. SSH предоставляет несколько методов аутентификации. Кроме "password" есть ещё весьма удобный и вполне безопасный "publickey".
У клиента всегда есть ключ. Без ключа нельзя подключиться. Публичный ключ клиента можно увидеть в файле ~/.ssh/id_rsa.pub.
$ cat ~/.ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCcwgyvek9Oh5wUNbNUTiEtz1jeRn6WnF1mw3duhPC7B3N9HAvCVtYesu5xLy6wylcKYUdKPLo8Q6hEh8kTcHwL21xPQB4/Uq/PjLUvt+MS5mUfYKwWL/M9h37nztA/scK6ItYWMxP6hsX1/zhOkcw1VsLD0+tHRYVqmAm+qO2VQxJ4Gc0dJWeHIGPq1gLLLlJx1QJHUMbHFewtH8z18ood3w/Q07QIKy2kMxFTK/y6Kv1Ij0rxO1KnnzulJHOiNIffeec7nvjcwe0nLYW7y28sT9cxCBIxNu2Dzir8pqgM1TB+mAw/nsYft9CGYBWc+AaodERHXPIbm2wwUUFWnEAN [email protected]
Файл не обязательно будет называться id_rsa.pub. Например, сейчас модно переходить на криптографию на эллиптических кривых. Тогда файлик будет называться id_ed25519.pub. Главное, смотрите файлики, чьё имя заканчивается на .pub. Это — публичные ключи. А без .pub — это приватные ключи. Берегите приватные ключи как зеницу ока и никому их не давайте даже под страхом смерти. И задавайте хороший пароль при генерации ключей.
$ ssh-keygen -o -a 100 -t ed25519 -f ~/.ssh/id_ed25519 -C "[email protected]"
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/gelin/.ssh/id_ed25519.
Your public key has been saved in /home/gelin/.ssh/id_ed25519.pub.
The key fingerprint is:
SHA256:FOjHEnorwa9eAb4Jhnqeh4rZ5zzsG/S8LzkBBm+DVCc [email protected]
The key's randomart image is:
+--[ED25519 256]--+
|   .E ...        |
|  o  oo  .       |
| . =.o o.        |
| ...X.+.o        |
|. ooo*.+S        |
|.. o.=+.         |
|. .o+o+o         |
|.=.o*o+.         |
|+.+**o.+.        |
+----[SHA256]-----+
А публичные ключи можно раздавать налево и направо. Что я тут и делаю :) Всё равно, всё, что вы сможете с ними сделать, это дать мне доступ к вашим серверам :)
Чтобы аутентификация по публичному ключу заработала, ключ клиента (публичный!) нужно добавить в файл ~/.ssh/authorized_keys на сервере. В домашнем каталоге того пользователя, под кем мы подключаемся. Соответственно, домашний каталог у этого пользователя должен быть.
Добавить ключ в authorized_keys можно либо руками. Просто дописываете содержимое id_rsa.pub (одну строчку) в конец этого файла. Не забудьте пустую строку в конце. Либо вам может помочь команда ssh-copy-id.
$ ssh-copy-id -i ~/.ssh/id_ed25519 [email protected]
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/gelin/.ssh/id_ed25519.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh '[email protected]'"
and check to make sure that only the key(s) you wanted were added.
Но мы же хотели ещё запретить подключение с паролем и прямой root доступ. Надо настроить sshd демона. Обычно его конфигурация лежит в файлике /etc/ssh/sshd_config. Нас интересуют несколько параметров.
PermitRootLogin no
PubkeyAuthentication yes    # не путать с RSAAuthentication
PasswordAuthentication no
После изменений в sshd_config делайте service ssh reload, не restart. И тренируйтесь в переподключении через SSH в другой консоли. Запускайте ssh с ключиком -v, чтобы видеть, как клиент с сервером договариваются. А эту сессию, где вы конфиг правили, не закрывайте. Тогда ваше подключение останется живым, даже если вы отломали SSH доступ. Крайне неприятно терять связь с удалённым сервером, знаете ли.
У клиента тоже есть конфиг. В файле ~/.ssh/config. Туда очень удобно прописывать имена юзеров и нестандартные порты подключения к конкретным серверам. Или даже какие-то особые SSH ключи, раз уж их вам выдали.
Host host.example.net
User root
Port 2222
IdentityFile ~/.ssh/example_net_key
Как вы знаете, Git и Mercurial тоже работают через SSH. Вот такой хитрой конфигурацией можно ходить на какой-нибудь Bitbucket из-под разных аккаунтов (и с разными ключами):
Host bitbucket.org
# обычный bitbucket, который использует ключ id_rsa

Host work.bitbucket.org
# отдельный bitbucket с отдельным ключом
HostName bitbucket.org
# но на самом деле мы всё равно подключаемся к bitbucket.org
User git
# юзером git
IdentityFile ~/.ssh/[email protected]
# и используем этот специальный ключ
IdentitiesOnly yes
# и только его
~/.ssh/config разбит на секции по директивам Host. Host — это то, что вы пишите в командной строке.
$ git clone [email protected]:user/repo.git
Это не обязательно должен быть реально существующий хост, настоящий адрес указывается в HostName. Все последующие директивы относятся в предыдущему (последнему) Host.
По умолчанию SSH запускает shell пользователя на сервере. Но на самом деле SSH может выполнить совершенно любую команду на удалённом хосте. И stdin/stdout на клиенте станут stdin/stdout этой удалённой команды. В своё время мы так собирали статистику с роутеров, запуская удалённый скрипт на Perl, который плевался XML.
$ ssh [email protected] whoami
root
Через SSH можно передавать файлы. Это значительно безопаснее, чем какой-нибудь FTP, куда шифрование толком так и не прикрутили. И это даже может быть быстрее.
Простой способ: scp. Secure CoPy. Можно скопировать локальный файл на удалённый сервер, или наоборот.
$ scp ssh.md [email protected]:/tmp/
ssh.md                  100%   13KB  74.6KB/s   00:00
Параметр, указывающий файл или каталог на удалённом хосте, состоит из двух частей. До двоеточия: имя пользователя и хост, так же как в обычном SSH клиенте. После двоеточия: абсолютный или относительный (относительно домашнего каталога) путь на сервере.
Но scp — туп. Он всегда копирует файл целиком. А вот если файл большой, или файлов много, нужно использовать rsync.
rsync — умный. Он умеет делить файлы на куски и проверять каждый кусок на идентичность, и докачивать только недостающие или отличающиеся куски. Он может сжимать файлы перед отправкой. Он может, как оно следует из названия, синхронизировать целые каталоги. С его помощью легко можно почти полностью склонировать почти любую Unix систему. Нужно только SSH на удалённый сервер, и установленный там rsync.
$ rsync -avz --progress . [email protected]:/tmp/test
sending incremental file list
created directory /tmp/test
./
00 URIs.png
        100,554 100%    4.31MB/s    0:00:00 (xfr#1, to-chk=2/4)
01 URI & URL
         38,506 100%    1.84MB/s    0:00:00 (xfr#2, to-chk=1/4)
url.md
          8,761 100%  371.99kB/s    0:00:00 (xfr#3, to-chk=0/4)

sent 139,314 bytes  received 108 bytes  39,834.86 bytes/sec
total size is 147,821  speedup is 1.06
Будьте осторожнее с параметрами rsync. Он по-разному работает с файлами и каталогами. Если вам нужно синхронизировать содержимое двух уже существующих каталогов, и не нужно создавать новые подкаталоги там, куда вы синхронизируете, оба параметра-каталога должны заканчиваться на /.
Кстати, rsync прекрасно работает и без SSH, на localhost.
Усложняем. Допустим, у нас есть какой-то Continuous Integration, который собирает некий статический сайт. И мы хотим, чтобы по окончанию сборки обновлённые файлы сами заливались бы на сервер. rsync для этого идеально подходит. Но мы не настолько доверяем нашему CI, чтобы давать ему root доступ. Заводим отдельного пользователя для синхронизации. Но хочется этого пользователя ещё сильнее ограничить, чтобы он мог делать только rsync только конкретного каталога. (Ну а в моём случае просто не было возможности завести ещё одного пользователя, ибо это shared hosting.) И в SSH это можно.
В файле authorized_keys перед ключом можно указать ещё и команду, которая будет разрешена к выполнению клиенту, предъявившему этот ключ. И больше никакая другая. И ещё кучу опций. А ещё есть специальный скрипт на Perl, под названием rrsync, который ограничивает доступ rsync к одному каталогу.
command="$HOME/bin/rrsync ~/limited/path/",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding ssh-rsa ...
Как видите, SSH много чего может. И это много чего тут явно запрещается.
Примерно так, через хитрые command для каждого ключа, и работают публичные Git репозитории вроде GitHub или GitLab. Хотите поднять что-то подобное (ограничиваясь только Git, безо всяких трекеров задач) у себя локально, смотрите в сторону gitolite.
У SSH есть агент. Он может хранить в памяти расшифрованные ключи, чтобы вам не приходилось вводить пароль для ключа при каждом подключении. А ещё он может пробрасывать загруженные на клиенте ключи так, чтобы они были доступны на сервере (это и называется agent forwarding). Это очень удобно, если вам нужно с сервера делать SSH на следующий сервер. Так бывает, да.
Добавить ключ в память агента можно так:
$ ssh-add ~/.ssh/special_key.pem
Identity added: /home/gelin/.ssh/special_key.pem (/home/gelin/.ssh/special_key.pem)
Через SSH можно пробросить порты (port forwarding). То есть сделать так, чтобы, например, подключения на локальный TCP порт 8080 перенаправлялись на порт 80 некоторого процесса на сервере. При этом прямого подключения к 80 порту на сервере у вас нет. Но у вас есть SSH доступ на сервер.
$ ssh -L 8080:localhost:80 -N -f [email protected]
Это называется local forwarding. В данном случае слушается локальный порт 8080 клиента. На сервере происходит подключение к localhost:80 (можно перенаправить подключение и к другому хосту, доступному с сервера). SSH будет только пробрасывать порт, но не будет запускать shell на сервере: -N. SSH клиент уйдёт работать в фон: -f.
Local Forwarding
Можно и наоборот. Открыть на сервере порт для подключения, и все подключения на этот порт будут проброшены на другой хост и порт, доступные на клиенте. Это называется remote forwarding. Синтаксис такой же, но нужен ключик -R.
Remote Forwarding
SSH может пробрасывать и сессии X11. Если помните, протокол X, который всё ещё доминирует на наших юниксах как средство отрисовки гуя, обладает сетевой прозрачностью. Собственно, в Linux когда-то TCP/IP запихнули в спешке, лишь бы графические окошки рисовались. А значит, имея запущенный X server на SSH клиенте, вполне можно запустить гуёвое приложение где-нибудь на мощном сервере так, чтобы рисовало окошки оно на клиенте. И всё, что вам для этого нужно, это xlib на сервере и SSH доступ туда. (Запутались, кто клиент, а кто сервер? Правильно, в иксах это нормально.)
SSH может не просто пробрасывать порты на конкретные хосты, но и выступать в роли SOCKS сервера.
$ ssh -D 1080 -f -C -q -N [email protected]
Здесь у вас на локальном порту 1080 клиента появится SOCKS сервер, который может подключаться к любому хосту, доступному с сервера. Все подключения будут проброшены через SSH. Данные будут сжиматься: -C.
Конечно, полноценные VPN туннели работают быстрее и лучше. Но и SSH, как видите, вполне достаточно.
SOCKS Proxy
Ну и, конечно же, Ansible работает через SSH. Он загружает и выполняет свои скрипты на Python через SSH.
Делайте "ssh". Это очень мощная и полезная штука.

2018-09-29

Об URL

Понадобилось как-то распарсить вот такую строчку. Это первая строка типичного HTTP запроса.
POST /service?user=123 HTTP/2.0
Такие строчки встречаются в логах Elastic Load Balancer (ELB). Я специально засунул туда айдишник пользователя, чтобы отделять запросы одних пользователей от запросов других. Да, Chrome, которым мы в основном пользуемся, ходит в Амазоновый ELB по протоколу HTTP/2.
Разбить строку по пробелам — легко. Но в серединке у нас URL. И мне нужно извлечь из этого URL path и один из параметров запроса. Регулярные выражения для URL я писать не хочу, неблагодарное это дело. Ведь должны же быть стандартные парсеры для URL или URI.
URIs
Раз уж у нас Java/Kotlin, давайте попробуем java.net.URL.
val url = java.net.URL("/service?user=123")
> java.net.MalformedURLException: no protocol: /service?user=123
Ну да. У нас же немножко неполный, прямо говоря, относительный, URL, каким он обычно и бывает в атрибуте href или заголовке HTTP запроса.
Ну давайте попробуем java.net.URI.
val url = java.net.URI("/service?user=123")
url.getPath()
> /service
url.getQuery()
> user=123
Победа? Ещё нет. У java.net.URL и у java.net.URI есть метод getQuery(). Он выделяет query часть URL, но не парсит её. Далее StackOverflow рекомендует снова воспользоваться регулярными выражениями или хотя бы разбить строку по символам "&" и "=".
Но я не хочу писать свой парсер. В любом парсере рано или поздно найдутся ошибки или уязвимости. И лучше, чтобы это был не ваш парсер. Тем более, что для такого популярного случая, как URL, уж точно должно существовать готовое решение. Почему это Java должна быть обделена?
Тем, кто под Android, повезло. android.net.Uri делает то, что нужно. Небольшая сложность возникнет при конструировании этого Uri. Фабричный метод Uri.fromParts(String scheme, String ssp, String fragment) требует явного указания схемы. А дальше у нас есть getQueryParameter(String key).
В мире Spring всё тоже неплохо. Там есть UriComponentsBuilder. С его фабричными методами тоже нужно разобраться, их много. И делает он UriComponents. А там уже есть MultiValueMap getQueryParams(). Даже круче, чем нужно.
Но у меня не Android. И я не хочу тащить Spring. Потому что это Lambda. Чем меньше классов и зависимостей, тем лучше.
Схема. Почему схема нужна явно? Если копнуть, окажется очень интересно.
URI — это не только URL. Есть ещё URN, где (например, "urn:isbn:5170224575") нет никакого пути или какой-либо иерархии. Есть просто имя в определённом пространстве имён. В "mailto:[email protected]" есть такие части URL, как имя пользователя и адрес сервера, но больше нет ничего. "tel:+1-816-555-1212" вообще ничего общего с HTTP URL не имеет.
Только URL имеет и имя хоста, и иерархический путь, и query, и fragment. И то лишь схемы "http", "https" и "ftp". Даже у наиболее близкого "file" уже нет имени хоста.
URI & URL
В мире URI — полный бардак. Но хорошая новость в том, что c любым URI (и, соответственно, URL) можно понять, как разобраться, выяснив схему. То есть, прочитав ASCII символы до первого двоеточия. Схема — важна.
В моём случае URL относительный. В нём пропущена схема и доменное имя. Схему нужно указать. И в данном случае всё просто. Это либо "http", либо "https", без разницы.
Проблема разбора URI/URL действительно является проблемой. Поэтому возникают библиотечки со странными именами вроде galimatias. Либо HTTP библиотеки обзаводятся своими реализациями методов работы с URL. Мало кого удовлетворяет стандартная библиотека Java.
Я остановился на прекрасной библиотеке OkHttp. Это — мощный (но лёгкий) HTTP клиент. Который, кстати, стал дефолтной подкапотной реализацией HTTP в последних версиях Android.
Там есть свой HttpUrl, который может почти всё, что нужно. Но он работает только с "http" и "https" схемами URL. Потому что он заточен на такие URL, и умеет справляться с различными кодировками не-ASCII символов в доменной части и в пути. Ну и, конечно же, он корректно парсит query. Почитайте JavaDoc, там подробно расписано, почему так, и чем ещё плох java.net.URL (спойлер: метод equals() там ходит в сеть).
Так что делать с относительным URL в моём случае? Считать его относительным. И делать resolve() от некоторого базового URL.
val baseUrl = HttpUrl.get("https://example.com")  // пофиг какой сервер
val url = baseUrl.resolve("/service?user=123")
val pathSegments = url?.pathSegments()
val user = url?.queryParameter("user")
Это работает точно так же, как разрешение href ссылки в HTML. Если будет относительный URL вроде "/service?user=123", то это отрезолвится в "https://example.com/service?user=123". Если будет абсолютный URL вроде "https://example.net/service?user=123", то это отрезолвится в новый абсолютный URL "https://example.net/service?user=123". То, что нужно.